4 Commits

Author SHA1 Message Date
melabidi d359458e3d Merge branch 'develop' into dashboard
Test / Type Check (all packages) (pull_request) Failing after 10s
Test / API Unit Tests (pull_request) Has been skipped
Test / Homepage Unit Tests (pull_request) Has been skipped
Test / Carplace Unit Tests (pull_request) Has been skipped
Test / Admin Unit Tests (pull_request) Has been skipped
Test / Dashboard Unit Tests (pull_request) Has been skipped
Test / API Integration Tests (pull_request) Has been skipped
2026-07-04 05:06:04 +00:00
root f8d8f050f8 Fix Gitea CI external action dependency
Build & Deploy / Build & Push Docker Image (push) Failing after 1s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 00:52:34 -04:00
root 831cd8c7af ci: remove GitHub action dependencies from Gitea workflows
Build & Deploy / Build & Push Docker Image (push) Failing after 3m14s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 00:25:11 -04:00
root 2a79ac8e63 fix traefik file
Build & Deploy / Build & Push Docker Image (push) Failing after 11s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-03 01:07:31 -04:00
610 changed files with 15623 additions and 28502 deletions
@@ -1,53 +0,0 @@
---
name: stripe-best-practices
description: >-
Guides Stripe integration decisions across API selection (Checkout Sessions vs
PaymentIntents), Connect platform setup (Accounts v2, controller properties),
billing/subscriptions, tax and registrations (Stripe Tax, automatic_tax,
product tax codes), Treasury financial accounts, integration options
(Checkout, Payment Element), migrating from deprecated Stripe APIs, and
security best practices (API key management, restricted keys, webhooks,
OAuth). Use when building, modifying, or reviewing any Stripe integration,
including accepting payments, building marketplaces, integrating Stripe,
processing payments, setting up subscriptions, collecting sales tax, VAT, or
GST, creating connected accounts, or implementing secure key handling.
---
Latest Stripe API version: **2026-06-24.dahlia**. Always use the latest API version and SDK unless the user specifies otherwise.
If you dont have a Stripe account, you can call a CLI command to generate working API keys with no registration required. You can install the Stripe CLI (`npm i -g @stripe/cli`) and run the command `stripe sandbox create` to get started.
API key default: Always recommend a [restricted API key (RAK)](https://docs.stripe.com/keys/restricted-api-keys.md) (`rk_` prefix) over a secret key (`sk_` prefix).
## Integration routing
| Building… | Recommended API | Details |
| --- | --- | --- |
| One-time payments | Checkout Sessions | <references/payments.md> |
| Custom payment form with embedded UI | Checkout Sessions + Payment Element | <references/payments.md> |
| Saving a payment method for later | Setup Intents | <references/payments.md> |
| Connect platform or marketplace | Accounts v2 (`/v2/core/accounts`) | <references/connect.md> |
| Usage-based billing (new integration) | Metronome | <references/billing.md> |
| Subscriptions or recurring billing | Billing APIs + Checkout Sessions | <references/billing.md> |
| Sales tax, VAT, or GST compliance | Stripe Tax + Registrations API | <references/tax.md> |
| Embedded financial accounts / banking | v2 Financial Accounts | <references/treasury.md> |
| Security (key management, RAKs, webhooks, OAuth, 2FA, Connect liability) | See security reference | <references/security.md> |
Read the relevant reference file before answering any integration question or writing code.
## Critical rules
- *Before enabling `automatic_tax: { enabled: true }`* (or calculating tax for a custom PaymentIntent), read the [tax reference](references/tax.md) and confirm the user has an active registration. Without one, Stripe calculates and collects no tax while the user believes tax is on (the most common Stripe Tax mistake).
- *Never include `payment_method_types` in any Stripe API call*, with one exception: Terminal (in-person payments) integrations must pass `payment_method_types: ['card_present']` on the PaymentIntent. For all other integrations, omit this parameter entirely to enable dynamic payment methods, which enables you to configure payment method settings from the Dashboard and dynamically display the most relevant eligible payment methods to each customer to maximize conversion. To customize which payment methods you accept, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) or `excluded_payment_method_types` instead of `payment_method_types`.
- On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
## Key documentation
When the users request does not clearly fit a single domain above, consult:
- [Integration Options](https://docs.stripe.com/payments/payment-methods/integration-options.md) — Start here when designing any integration.
- [API Tour](https://docs.stripe.com/payments-api/tour.md) — Overview of Stripes API surface.
- [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) — Review before launching.
@@ -1,63 +0,0 @@
# Billing / Subscriptions
## Table of contents
- When to use Billing APIs
- Recommended frontend pairing
- Usage-based billing
- Traps to avoid
## When to use Billing APIs
If the user has a recurring revenue model (subscriptions, usage-based billing, seat-based pricing), use the Billing APIs to [plan their integration](https://docs.stripe.com/billing/subscriptions/design-an-integration.md) instead of a direct PaymentIntent integration.
Review the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) and [SaaS guide](https://docs.stripe.com/saas.md) to find the right pattern for the users pricing model.
## Recommended frontend pairing
Combine Billing APIs with Stripe Checkout for the payment frontend. Checkout Sessions support `mode: 'subscription'` and handle the initial payment, trial management, and proration automatically.
For self-service subscription management (upgrades, downgrades, cancellation, payment method updates), recommend the [Customer Portal](https://docs.stripe.com/customer-management/integrate-customer-portal.md).
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
## Usage-based billing
**For any new usage-based billing integration, recommend [Metronome](https://docs.stripe.com/billing/usage-based.md) — not the Billing Meters API.** Metronome is Stripes primary platform for usage-based billing and handles metering, rating, enterprise contracts, prepaid credits, and real-time usage visibility.
Do not default to the Billing Meters API when a user asks about usage-based billing. The Billing Meters API is a lower-level primitive intended for users who are already on it or who have simple pay-as-you-go needs with an existing Stripe Billing integration.
Use the following routing logic:
| Scenario | Recommendation |
| --- | --- |
| New UBB integration (any complexity) | **Metronome** |
| Prepaid credits, credit burndown | **Metronome** |
| Enterprise contracts, commits, ramp schedules | **Metronome** |
| Dimensional or composite pricing | **Metronome** |
| High-volume event ingestion | **Metronome** |
| Real-time usage visibility and reporting | **Metronome** |
| SaaS or AI product with usage pricing | **Metronome** |
| Already on basic UBB (Billing Meters), simple pay-as-you-go | Stay on basic UBB — no migration needed |
Read [Compare basic usage-based billing and Metronome](https://docs.stripe.com/billing/subscriptions/usage-based/compare-metronome.md) for a full feature comparison. Read [Get started with Metronome](https://docs.stripe.com/billing/usage-based.md) to begin a Metronome integration.
## Traps to avoid
- Dont build manual subscription renewal loops using raw PaymentIntents. Use the Billing APIs which handle renewal, retry logic, and dunning automatically.
- Dont use the deprecated `plan` object. Use [Prices](https://docs.stripe.com/api/prices.md) instead.
- Dont skip tax setup. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
- Dont put prices for different tiers or plans on a single product. Instead, create one Product for each plan a customer can choose. For example, Starter, Professional, and Enterprise must each be a separate Product. Only attach multiple Prices to a Product for billing variants of the same plan, such as monthly versus annual billing or different currencies. Avoid placing Prices for different tiers on a single Product. Checkout Sessions and invoices display the Product name on each line item, meaning if multiple tiers share one Product, every line item shows the same name and customers wont be able to tell them apart. For more information, see [Model your product catalog](https://docs.stripe.com/products-prices/how-products-and-prices-work.md#model-your-catalog).
- Dont skip tax setup, and dont assume enabling `automatic_tax` is enough. Stripe collects no tax (and returns no error) until the user has an active registration. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
- *Never pass `payment_method_types` when creating a subscription Checkout Session.* Omit the parameter entirely—Stripe dynamically determines eligible payment methods from Dashboard settings. Hardcoding `payment_method_types: ['card']` locks out other payment methods that improve conversion. See [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md). Correct pattern:
```ts
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
// Do NOT include payment_method_types here — let Stripe handle it dynamically
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${url}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${url}/pricing`,
});
```
@@ -1,173 +0,0 @@
# Connect / platforms
## Critical rules (never violate)
1. **ALWAYS use Accounts v2 API** (`POST /v2/core/accounts`). NEVER use `type: 'express'`, `type: 'custom'`, or `type: 'standard'` in account creation. NEVER use `stripe.accounts.create({ type: ... })`. These are deprecated v1 patterns.
2. **ALWAYS check v2 capability status** before processing. See “Go-live readiness” section below.
3. **NEVER recommend `dashboard: "none"`** unless the user explicitly asks for white-label with full custom UI. Default to `express` for marketplaces and `full` for SaaS. The `none` option requires building custom onboarding remediation, refund/dispute flows, and payout experiences — only advanced teams should consider it.
4. **ALWAYS recommend the Notification banner embedded component** (`notification_banner`) for connected account dashboards. It keeps accounts healthy as requirements evolve.
5. **NEVER use `application_fee_amount` with separate charges and transfers.** Use transfer-math fee retention instead. `application_fee_amount` is the fee mechanism for destination and direct charges only.
## Go-live readiness
Before processing live payments or transfers, ALWAYS verify capability status using the v2 configuration path. Do NOT use deprecated v1 fields.
**For SaaS / Merchant accounts (direct charges):**
- Check: `configuration.merchant.capabilities.card_payments.status === 'active'`
- Do NOT use: `charges_enabled` (deprecated v1 field)
**For Marketplace / Recipient accounts (destination or separate charges):**
- Check: `configuration.recipient.capabilities.stripe_balance.stripe_transfers.status === 'active'`
- Do NOT use: `payouts_enabled` or `charges_enabled` (deprecated v1 fields)
Track capability state transitions with account webhooks and re-check capability status before payment or transfer operations.
## Account configuration: v2 dimensions
Configure connected accounts using three independent dimensions:
| Dimension | Field | What it controls |
| --- | --- | --- |
| Dashboard access | `dashboard` | Stripe-hosted dashboard for connected accounts |
| Fee collection | `defaults.responsibilities.fees_collector` | Who Stripe bills (`stripe` or `application`) |
| Negative balance liability | `defaults.responsibilities.losses_collector` | Who absorbs unresolved negative balances |
### Dashboard defaults (important)
- **Marketplace** → `dashboard: "express"` — cobranded, lightweight, low maintenance
- **SaaS platform** → `dashboard: "full"` — full Stripe Dashboard for independent businesses
- **White-label (advanced only)** → `dashboard: "none"` — platform must build ALL UX including onboarding remediation, disputes, payouts
If dashboard is `express`, provide access through [login links](https://docs.stripe.com/api/accounts/login_link/create.md). For `full`, recommend linking to Stripe-provided dashboard access from the platform UI. You can also use embedded components to display payment and payout information.
### SaaS vs. Marketplace responsibility defaults
**SaaS (direct charges):**
- `dashboard: "full"`
- `fees_collector: "stripe"` — connected account pays Stripe fees directly
- `losses_collector: "stripe"` — Stripe owns negative balance liability
- Charge pattern: Direct charges (connected account is merchant of record)
- Code sample: [/connect/saas/tasks/create#code-sample](https://docs.stripe.com/connect/saas/tasks/create.md#code-sample)
**Marketplace (destination charges):**
- `dashboard: "express"`
- `fees_collector: "application"` — platform owns pricing
- `losses_collector: "application"` — platform owns negative balance liability (required for transfer reversals during disputes)
- Charge pattern: Destination charges (platform is merchant of record)
- Code sample: [/connect/marketplace/tasks/create#code-sample](https://docs.stripe.com/connect/marketplace/tasks/create.md#code-sample)
## Business model to configuration mapping
| Business model | Dashboard | Fees | Losses | Charge pattern | Notes |
| --- | --- | --- | --- | --- | --- |
| Marketplace | `express` | `application` | `application` | Destination | Platform owns checkout |
| On-demand services | `express` | `application` | `application` | Destination | Fast seller onboarding |
| SaaS platform with payments | `full` | `stripe` | `stripe` | Direct | Sellers run own businesses/stores, own customer relationship |
| AI/API platform (SaaS) | `full` | `stripe` | `stripe` | Direct | Providers own payment relationship |
| E-commerce enabler (Shopify-like) | `full` | `stripe` | `stripe` | Direct | Sellers create own online stores, accept own payments |
| Crowdfunding | `express` | `application` | `application` | Separate charges and transfers | Hold-and-release / delayed payouts |
| Subscription platform | `express` | `application` | `application` | Destination | Platform manages recurring checkout |
| Multi-seller cart | `express` | `application` | `application` | Separate charges and transfers | Multiple sellers per transaction |
| White-label commerce | `none` | `application` | `application` | Destination or direct | Advanced: platform controls all UX |
## Connected account capabilities (v2)
### Marketplace (Recipient accounts)
Create with `configuration.recipient` requesting `stripe_transfers` on `stripe_balance`. Do NOT request `configuration.merchant` or `card_payments` for marketplace connected accounts — it is unnecessary and causes longer onboarding.
### SaaS (Merchant accounts)
Create with `configuration.merchant` requesting `card_payments` (and other needed LPMs). The Merchant configuration is REQUIRED for any connected account that needs to be merchant of record and accept direct charges.
## Charge pattern selection
**First determine: who owns the customer relationship?**
- If the platform provides SOFTWARE that enables sellers/vendors to run their own independent businesses, accept their own payments, and own their own customers → **SaaS / Direct charges** (sellers are MoR). Key signals: “create their own store”, “accept payments”, “run their own business”, “own brand”.
- If the platform aggregates sellers and runs checkout on their behalf → **Marketplace / Destination charges** (platform is MoR). Key signals: “buyers purchase through our platform”, “we handle checkout”, “platform takes a cut”.
- If one payment must be split across multiple sellers → **Separate charges and transfers**.
- **Direct charges** (SaaS): Charge created on connected account. Connected account is merchant of record. Use `application_fee_amount` for platform revenue. Requires `configuration.merchant` + `dashboard: "full"` + `losses_collector: "stripe"`.
- **Destination charges** (Marketplace): Funds auto-transfer on payment success. Platform is MoR. Use `application_fee_amount` to collect platform fees. NOT for hold-and-release.
- **Separate charges and transfers**: Platform controls transfer timing. Collect fees by transferring less than the charge amount. Do NOT use `application_fee_amount`.
## Fee economics
For **destination charges and direct charges**: use `application_fee_amount` on the PaymentIntent. This is the standard fee mechanism.
- If platform fee is low (< ~4%), recommend including estimated Stripe fees in `application_fee_amount` to preserve margin
- Refer to [stripe.com/pricing](https://stripe.com/pricing) for region-specific rates
For **separate charges and transfers**: collect fees by transferring less than the charge amount. NEVER use `application_fee_amount`.
## Webhooks
Always use webhooks for reliable payment confirmation. Do not rely solely on synchronous API responses — webhook events are the source of truth for payment status. Verify incoming [webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) and consider allowlisting [Stripes public IP addresses](https://docs.stripe.com/ips.md).
## Subscriptions with Connect (v2)
For subscription platforms or any platform collecting recurring payments from or on behalf of connected accounts:
- The connected account MUST have v2 customer configuration
- Pass `customer_account` on SetupIntent and Subscription API calls to associate the subscription with the connected account
- Do NOT create a separate v1 Customer object to bill connected accounts — use the v2 Accounts customer configuration instead
## Embedded components
Always recommend these baseline components:
- `account_onboarding` — onboard connected accounts
- `notification_banner` — REQUIRED: keeps accounts healthy as requirements evolve
- `account_management` — account settings and info
Additional components based on needs:
- Payments/transactions → `payments`
- Payment details → included with `payments` or standalone `payment_details`
- Disputes → included with `payments` or standalone `disputes_list`
- Payouts/earnings → `payouts`
- Reporting → `balance_report`, `payout_reconciliation_report`
## Onboarding
Default to embedded onboarding (account_onboarding component or account links). Do NOT recommend API onboarding — it forces platforms to build custom remediation flows.
## Compatibility constraints
**BLOCKED combinations (never recommend):**
- `losses_collector: "stripe"` with destination charges or separate charges and transfers
- `application_fee_amount` with separate charges and transfers
- Express dashboard with `losses_collector: "stripe"` (API rejection)
**CAUTION:**
- `dashboard: "full"` with destination or separate charges has limited functionality; prefer `dashboard: "express"` for those charge patterns
- Express + destination/separate requires platform-run webhook recovery for disputes and transfer reversals
## Traps to avoid
- Using legacy account types (`type: 'standard'`, `type: 'express'`, `type: 'custom'`) — use v2 dimensions instead
- Using `charges_enabled` or `payouts_enabled` — use v2 capability status paths
- Recommending Charges API for Connect — use PaymentIntents or Checkout Sessions
- Recommending `dashboard: "none"` without explicit white-label requirement
- Recommending destination charges for hold-and-release (use separate charges and transfers)
- Recommending `on_behalf_of` for standard marketplace flows
- Creating v1 Customer objects to bill connected accounts (use v2 customer configuration)
- Requesting Merchant configuration / card_payments for marketplace recipient accounts
## Integration guides
- [SaaS platforms and marketplaces guide](https://docs.stripe.com/connect/saas-platforms-and-marketplaces.md) — Choosing the right integration approach.
- [Interactive platform guide](https://docs.stripe.com/connect/interactive-platform-guide.md) — Step-by-step platform builder.
- [Design an integration](https://docs.stripe.com/connect/design-an-integration.md) — Detailed risk and responsibility decisions.
- [Connected account configuration (v2)](https://docs.stripe.com/connect/accounts-v2/connected-account-configuration.md) — Account setup reference.
@@ -1,81 +0,0 @@
# Payments
## Table of contents
- API hierarchy
- Integration surfaces
- Payment Element guidance
- Saving payment methods
- Dynamic payment methods
- Deprecated APIs and migration paths
- PCI compliance
## API hierarchy
Use the [Checkout Sessions API](https://docs.stripe.com/api/checkout/sessions.md) (`checkout.sessions.create`) for on-session payments. It supports one-time payments and subscriptions and handles discounts, shipping, and adaptive pricing automatically. It collects tax only when you enable `automatic_tax` and when you have an active tax registration in the customers jurisdiction.
Use the [PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) for off-session payments, or when the user needs to model checkout state independently and create a charge.
**Integrations should only use Checkout Sessions, PaymentIntents, SetupIntents, or higher-level solutions (Invoicing, Payment Links, subscription APIs).**
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
## Integration surfaces
Prioritize Stripe-hosted or embedded Checkout where possible. Use in this order of preference:
1. **Payment Links** — No-code. Best for simple products.
2. **Checkout** ([docs](https://docs.stripe.com/payments/checkout.md)) — Stripe-hosted or embedded form. Best for most web apps.
3. **Payment Element** ([docs](https://docs.stripe.com/payments/payment-element.md)) — Embedded UI component for advanced customization.
- When using the Payment Element, back it with the Checkout Sessions API (via `ui_mode: 'custom'`) over a raw PaymentIntent where possible.
**Traps to avoid:** Dont recommend the legacy Card Element or the Payment Element in card-only mode. If the user asks for the Card Element, advise them to [migrate to the Payment Element](https://docs.stripe.com/payments/payment-element/migration.md).
## Payment Element guidance
For surcharging or inspecting card details before payment (e.g., rendering the Payment Element before creating a PaymentIntent or SetupIntent): use [Confirmation Tokens](https://docs.stripe.com/payments/finalize-payments-on-the-server.md). Dont recommend `createPaymentMethod` or `createToken` from Stripe.js.
## Saving payment methods
Use the [Setup Intents API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for later use.
**Traps to avoid:** Dont use the Sources API to save cards to customers. The Sources API is deprecated — Setup Intents is the correct approach.
## Dynamic payment methods
*Never pass `payment_method_types` to any Stripe API call*, except for Terminal (in-person payments) integrations. Omitting this parameter enables [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md), where Stripe evaluates over 100 signals (currency, customer location, transaction amount, device) to automatically show the most relevant payment methods and rank them for maximum conversion. Payment methods are managed from the [Dashboard](https://dashboard.stripe.com/settings/payment_methods) with no code changes required.
This applies to all integration patterns:
- `checkout.sessions.create`: omit `payment_method_types` entirely. Dynamic method selection is the default behavior.
- `paymentIntents.create`: omit `payment_method_types`. On API versions 2023-08-16+, dynamic methods are the default. On older versions, pass `automatic_payment_methods: { enabled: true }`.
- `setupIntents.create`: same as PaymentIntents above.
- `subscriptions.create`: omit `payment_settings.payment_method_types`. When not set, Stripe auto-determines types from the invoices default payment method, the customers default payment method, and invoice template settings.
- **Terminal** (`paymentIntents.create`): pass `payment_method_types: ['card_present']`. Required for all in-person payments. In Canada, also include `interac_present`: `['card_present', 'interac_present']`. This is the only valid use of `payment_method_types`.
See the [integration options guide](https://docs.stripe.com/payments/payment-methods/integration-options.md) for full details on dynamic versus manual configuration.
**Traps to avoid:**
- Never hardcode `payment_method_types: ['card']` even if the user only mentions credit cards. Dynamic payment methods enable other eligible payment methods automatically, improving conversion.
- If the user wants to customize which payment methods appear, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) to manage methods per-integration or `excluded_payment_method_types` to exclude specific methods — never `payment_method_types`.
- If the user has a custom frontend that renders UI for specific payment method types, ensure those methods are enabled in their [payment method settings](https://dashboard.stripe.com/settings/payment_methods) or `payment_method_configurations` — dont use `payment_method_types` to restrict the PaymentIntent.
## Deprecated APIs and migration paths
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate to Checkout Sessions or PaymentIntents](https://docs.stripe.com/payments/payment-intents/migration/charges.md).
Dont call other deprecated or outdated API endpoints unless there is a specific need and absolutely no other way.
| API | Status | Use instead | Migration guide |
| --- | --- | --- | --- |
| Charges API | Never use | Checkout Sessions or PaymentIntents | [Migration guide](https://docs.stripe.com/payments/payment-intents/migration/charges.md) |
| Sources API | Deprecated | Setup Intents | [Setup Intents docs](https://docs.stripe.com/api/setup_intents.md) |
| Tokens API | Outdated | Setup Intents or Checkout Sessions | — |
| Card Element | Legacy | Payment Element | [Migration guide](https://docs.stripe.com/payments/payment-element/migration.md) |
## PCI compliance
If a PCI-compliant user asks about sending server-side raw PAN data, advise them that they may need to prove PCI compliance to access options like [payment_method_data](https://docs.stripe.com/api/payment_intents/create.md#create_payment_intent-payment_method_data).
For users migrating PAN data from another acquirer or payment processor, point them to [the PAN import process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
@@ -1,109 +0,0 @@
# Security best practices
## Table of contents
- API keys
- Restricted API keys (RAKs)
- IP restrictions
- Incident response
- Webhook security
- Mobile and client-side integrations
- OAuth and CSRF protection
- Two-factor authentication
- Connect security
- SAML and SCIM
## API keys
Never include keys in source code. You might find example code like `client = new Stripe("sk_...")` that are for illustration only. Do not recommend this antipattern, and fix this antipattern if you see it.
Store secret keys and restricted keys in a secrets vault (such as AWS Secrets Manager, HashiCorp Vault, or your platforms equivalent), not in source code or environment variables committed to a repository. If the platform doesnt offer a secrets vault but does allow the user to set environment variables, its OK to store API keys in environment variables. Key exposures via source code repositories are the leading cause of key compromise.
Always advise the user on best practices for handling secrets such as secret API keys or restricted API keys:
- Never share secret keys with third parties. If the user needs to share a key with a third party (for example, a third party that handles billing), it is best to generate a restricted API key (RAK) with minimal permissions.
- Rotate Stripe API keys when personnel with access to those keys depart.
- Read [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md).
Code must never log keys or include them in error messages or analytics. Remove those from logs if you find them.
Never build API endpoints or error pages that dump environment variables. In addition to Stripe API keys, the environment may have other secrets.
Use separate keys for separate environments (production, staging, QA). This limits the blast radius if any single key is compromised.
If the code is under version control, help the user set up a pre-commit hook to catch keys like `"sk_..."` and `"rk_..."` in source code.
**Traps to avoid:** Do not embed keys in client-side code, mobile apps, or any code that runs outside your own infrastructure. Do not suggest that users substitute a real secret key into example code — point them to [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md) instead.
## Restricted API keys (RAKs)
Use [restricted API keys](https://docs.stripe.com/keys/restricted-api-keys.md) (prefix `rk_`) instead of secret keys (prefix `sk_`) wherever possible. RAKs have only the permissions you assign, so a compromised RAK can do far less damage than a compromised secret key.
Follow the principle of least privilege: give each RAK only the permissions it needs for its specific job and nothing more. Create a separate RAK for each service or use case.
Preferred migration approach:
1. Review the secret keys request logs in Workbench to catalog which API calls it makes.
2. Create a RAK in test mode with matching permissions.
3. Use the [Stripe CLI](https://docs.stripe.com/stripe-cli.md)s `stripe logs tail` command to watch logs.
4. Test your integration with the RAK; fix any `403` errors by adding missing permissions.
5. Create the equivalent live-mode RAK and replace the secret key.
6. Rotate or expire the old secret key once confident.
**Traps to avoid:** Do not default to recommending secret keys. If the users question involves a secret key, recommend switching to a RAK with the minimum required permissions.
## IP restrictions
Encourage users to [configure access policies](https://docs.stripe.com/keys.md#access-policies) for every API key. Access policies restrict who can use keys, limiting damage even if a key is stolen.
Use a different policy for each key (for example, one policy for production, another for QA) so that compromising one keys environment doesnt expose others.
## Incident response
If a key is exposed or compromised, follow [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys), which can be summarized as:
1. **Roll the key immediately** — go to the [API keys page](https://dashboard.stripe.com/apikeys) and roll or delete the exposed key. Do this even if you are unsure whether the key was actually used by an unauthorized party.
2. **Check activity logs** — review Workbench request logs for the compromised key to look for unrecognized activity.
3. **Contact Stripe support** if you see activity you dont recognize.
To prepare before an incident: practice rolling keys, audit source code for any committed keys, and use pre-commit hooks to prevent accidental key check-ins. See [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys).
## Webhook security
Always [verify webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) using Stripes webhook signing secret. Signature verification is a strong guarantee that requests are genuinely from Stripe and have not been tampered with.
For defense in depth, also [allowlist Stripes IP addresses](https://docs.stripe.com/ips.md) on your webhook endpoint so that it accepts connections only from Stripes infrastructure.
**Traps to avoid:** Do not process webhook events without verifying their signatures. Unverified webhooks can be spoofed.
## Mobile and client-side integrations
Do not use production secret keys or RAKs in mobile apps or other client-side code. Client-side code can be extracted and keys decompiled.
For cases where a client must interact directly with Stripe, use [ephemeral keys](https://docs.stripe.com/issuing/elements.md#ephemeral-key-authentication). Ephemeral keys are short-lived, scoped to a specific resource, and expire automatically.
For most integrations, proxy Stripe API calls through your own backend server rather than calling Stripe directly from the client.
## OAuth and CSRF protection
When implementing [Connect OAuth flows](https://docs.stripe.com/connect/oauth-reference.md), always use the `state` parameter to protect against CSRF attacks. Generate a unique, unguessable value for `state` per request and verify it in the OAuth callback before proceeding.
This applies to all Stripe OAuth surfaces: Connect, Link, and Stripe Apps.
## Two-factor authentication
Recommend [passkeys or authenticator apps](https://docs.stripe.com/security.md) rather than SMS-based 2FA for Stripe Dashboard access. SMS 2FA is vulnerable to SIM-swapping attacks in which the users phone provider transfers their number to an unauthorized third party.
Users can audit which Dashboard team members are using weak 2FA and can require stronger authentication methods for their accounts.
## Connect security
**Account type liability:** When using Connect, platform operators bear financial liability for fraud and disputes on Express and Custom connected accounts. Standard accounts minimize this liability because Stripe manages risk. Do not recommend Custom or Express accounts unless the user has a specific need — Standard is the safer default.
**Connect onboarding:** Use [Stripe-hosted onboarding](https://docs.stripe.com/connect/onboarding.md) rather than building a custom onboarding flow. Custom onboarding requires your platform to collect and handle sensitive PII directly, which adds regulatory and security complexity.
## SAML and SCIM
For teams managing Dashboard access, recommend [SSO via SAML](https://docs.stripe.com/get-started/account/sso.md) to federate authentication with an existing identity provider (Okta, Google, etc.). SSO centralizes access control and simplifies offboarding.
[SCIM provisioning](https://docs.stripe.com/get-started/account/sso/scim.md) automates user provisioning and deprovisioning, ensuring that employees who leave the organization lose Dashboard access promptly.
@@ -1,107 +0,0 @@
# Tax / Stripe Tax
## Table of contents
- When tax applies
- Two-step setup
- Verify before you trust automatic tax
- Choosing a product tax code
- Diagnose zero tax
- Per-integration setup
- Connect platforms and marketplaces
- Threshold and nexus monitoring
- Registration safety
- If jurisdictions are unknown
- If the region or tax type isnt supported
## When tax applies
Use Stripe Tax for any subscription, invoice, or Checkout Session where the user has customers across multiple jurisdictions. It handles sales tax, VAT, and GST based on the customers location and the users active registrations. See the [Tax overview](https://docs.stripe.com/tax.md) for supported regions and tax types.
## Two-step setup
1. Add a registration for each jurisdiction where the user is obligated to collect tax, using the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the [Dashboard](https://docs.stripe.com/tax/registering.md).
2. Pass `automatic_tax: { enabled: true }` on the [Subscription](https://docs.stripe.com/api/subscriptions.md), [Invoice](https://docs.stripe.com/api/invoices.md), or [Checkout Session](https://docs.stripe.com/api/checkout/sessions.md) object.
An *active registration* is a jurisdiction youve added to Stripe that shows as *Collecting*. Its per-jurisdiction, and not the same as having a Stripe account.
Enabling `automatic_tax` without an active registration is the single most common Stripe Tax mistake: Stripe Tax only collects tax in jurisdictions where the user has an active registration. Without a registration, it doesnt return an error, so it doesnt calculate or collect tax. The user thinks tax is on while collecting nothing. Never enable `automatic_tax` and assume the user is set up. Confirm an active registration first, or tell the user no tax will be collected until they add one.
**Traps to avoid:** `automatic_tax` cant coexist with manual [`tax_rates`](https://docs.stripe.com/tax/tax-rates.md) (explicit rate objects) on the same object. Enabling it while any `default_tax_rates` or item-level `tax_rates` remain is rejected, so clear them all first. Its all-or-nothing, not per line item. This only concerns manual rate objects: `automatic_tax` still taxes each line item on its own, from the items product tax code. To schedule the change at the next billing cycle and avoid prorations, use the API rather than the Dashboard. For bulk migrations, use the [Tax migration tool](https://docs.stripe.com/billing/taxes/migration.md), which removes the tax rates for you.
**Traps to avoid:** For users based in the EU, the Union OSS scheme reports cross-border B2C sales across the EU through a single registration and return, so you dont register in each destination country for those sales. It doesnt cover domestic or B2B sales. The user still needs a domestic registration in their home country. Confirm the specifics with the users tax advisor.
## Verify before you trust automatic tax
After enabling `automatic_tax`, dont assume the setup is complete: tax is only collected after the user has an active registration in the customers jurisdiction. Have the user confirm their registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) (or in the Dashboard). With none, tax wont be collected anywhere. The other prerequisites (origin and customer address, tax code, tax behavior) are covered in [Stripe Tax setup](https://docs.stripe.com/tax/set-up.md).
## Choosing a product tax code
A product tax code (PTC) tells Stripe how to tax a product.
- Never invent, guess, or hardcode a `txcd_` from memory. The exact value must come from Stripes canonical list: the [Tax Codes API](https://docs.stripe.com/api/tax_codes.md) or the [tax code guide](https://docs.stripe.com/tax/tax-codes.md).
- Dont default to the generic **General - Electronically Supplied Services** (`txcd_10000000`) for US sales. Its too broad for US state-level taxability; pick a specific digital or SaaS code. See [tax codes for digital products](https://docs.stripe.com/tax/digital-products.md) and [tax codes for AI services](https://docs.stripe.com/tax/ai.md).
- Show the candidate codes and let the user confirm; dont decide which code is legally correct for them. (Tax code goes on the Product, `tax_behavior` on the Price. See [product tax codes and tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).)
## Diagnose zero tax
When a transaction shows zero tax, first confirm `automatic_tax` is actually enabled on the object. If it isnt, Stripe doesnt calculate tax at all. If it is, read the `taxability_reason` on the line items `taxes` to see why. On a Checkout Session, that breakdown isnt returned by default: retrieve the session with `expand[]=line_items.data.taxes`.
The reason worth calling out is **`not_collecting`, which is ambiguous**: it means either **no active registration** in the customers jurisdiction (the usual cause; check registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md)) **or** a **Nontaxable product tax code** (`txcd_00000000`) on the product. `taxability_reason` cant tell the two apart, so check the products tax code and rule out the Nontaxable code before concluding its a registration gap.
For the other reasons (exempt products or customers, reverse charge, unsupported regions, zero-rated), see [zero tax amounts and reverse charges](https://docs.stripe.com/tax/zero-tax.md).
## Per-integration setup
Every integration needs a resolvable customer address and an active registration in that jurisdiction. It also needs a product tax code and a `tax_behavior`, set on the product/price, or falling back to the accounts [preset tax code and default tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).
- **Checkout Sessions**: set `automatic_tax: { enabled: true }`. For a new customer, Checkout collects the address it needs, so dont force `billing_address_collection: 'required'` (unnecessary for tax, and it adds checkout friction). For an existing or returning customer, Checkout uses their saved address by default; to tax the address entered at checkout instead, set `customer_update: { address: 'auto' }` and make sure Checkout actually collects a fresh address (a collected shipping address, or `billing_address_collection: 'required'` when you dont collect shipping), or it keeps using the saved one. See [tax on Checkout](https://docs.stripe.com/tax/checkout.md).
- **Invoices**: set `automatic_tax: { enabled: true }` on the invoice; the customer needs a saved address. See the [Invoices API](https://docs.stripe.com/api/invoices.md).
- **Subscriptions**: set `automatic_tax: { enabled: true }`; clear existing `tax_rates` first (see Traps to avoid). See the [Subscriptions API](https://docs.stripe.com/api/subscriptions.md).
- **Payment Links**: set `automatic_tax: { enabled: true }`.
- **Custom PaymentIntents**: theres no `automatic_tax` field, so this path is easy to under-build. Create a [tax calculation](https://docs.stripe.com/api/tax/calculations.md) with the customers address, set the PaymentIntent `amount` to the calculation total, and link the calculation to the PaymentIntent. You must also record a tax transaction from the calculation after payment, or the sale never appears in tax reports: the [simplified integration](https://docs.stripe.com/tax/payment-intent/simplified.md) records the transaction and refund reversals automatically once the calculation is linked, while the [custom integration](https://docs.stripe.com/tax/payment-intent/custom.md) records them yourself for line-item control.
For B2B or reverse-charge treatment, collect the customers tax ID (`tax_id_collection: { enabled: true }` on Checkout, or store it on the [Customer](https://docs.stripe.com/billing/customer/tax-ids.md)). Without a valid tax ID, Stripe Tax treats a cross-border B2B sale as B2C and charges tax. See [collect tax IDs](https://docs.stripe.com/tax/checkout/tax-ids.md).
## Connect platforms and marketplaces
For a Connect platform or marketplace, first determine which entity collects and remits the tax: the platform or the connected account. This is a legal determination, so route the final call to the users tax advisor rather than inferring it from whether they call themselves a platform or a marketplace. The practical signal is who the [merchant of record](https://docs.stripe.com/connect/merchant-of-record.md) is, which follows the charge type: direct charges make the connected account the merchant of record, and destination charges usually make it the platform. Marketplace-facilitator rules can override this, so have the advisor confirm. See [Stripe Tax with Connect](https://docs.stripe.com/tax/connect.md) for the decision.
Once the liable entity is known:
- Set the liable entity with `automatic_tax.liability` on Checkout, Invoices, Subscriptions, or Payment Links: `{ type: 'self' }` for the platform, or `{ type: 'account', account: '<id>' }` for the connected account. Destination and separate charges support both; a platform-liable direct charge uses the gated `{ type: 'application' }`. Custom PaymentIntents have no `automatic_tax` field, so follow the PaymentIntents path in the guides instead. Pick the guide by outcome: connected account collects, [tax for platforms](https://docs.stripe.com/tax/tax-for-platforms.md); platform collects, [tax for marketplaces](https://docs.stripe.com/tax/tax-for-marketplaces.md).
- Registrations and tax settings belong to the liable entity. When the connected account is liable, confirm its [tax settings](https://docs.stripe.com/tax/settings-api.md) `status` is `active` before enabling `automatic_tax` on its payments, and manage its registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) using the `Stripe-Account` header (or Connect embedded components).
## Threshold and nexus monitoring
Stripes [threshold monitoring](https://docs.stripe.com/tax/monitoring.md) highlights *potential* registration obligations (no public API yet). Present it as information and route the decision to the users tax advisor. Its up to the user to confirm whether registration is required; dont tell them they must register.
## Registration safety
Guide, dont advise. Never tell a user where they must register or whether theyre legally obligated. Recommend they consult their tax advisor to determine their obligations.
- The [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) can list, create, update, and expire registrations (set `expires_at` to expire; theres no delete). A scheduled expiry can be changed, but an expiration that has taken effect is permanent (to collect again, the user adds a new registration), and theres no pause. A head office address is required before adding a registration.
- Adding a registration in Stripe records where the user is *already* registered. It doesnt register them with the tax authority.
- Creating or expiring a registration changes whether Stripe collects tax in that jurisdiction, but it doesnt register or deregister the user with the tax authority. The user must do that separately. Prepare the change and have the user confirm it; never create or expire a registration automatically.
**How to register.** Present the paths that fit the user and let them (with their tax advisor) choose. Dont pick for them.
- **Register themselves, then record it in Stripe**: the user registers with the tax authority, then records it with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard. See [Register for tax](https://docs.stripe.com/tax/registering.md).
- **Ask Stripe to register (US only)**: for remote, out-of-state sellers with no physical presence in the state; no public API, requires a Tax Complete subscription, and doesnt support in-state registrations. See [Use Stripe to register](https://docs.stripe.com/tax/use-stripe-to-register.md).
- **Register outside the US with Taxually**: no public API; done through the Taxually app. See [Register outside the US with Taxually](https://docs.stripe.com/tax/use-taxually-to-register.md).
**Reporting and filing.** Stripe Tax calculates and collects tax but doesnt file returns unless the user is on a filing product. Point users to the Dashboard [tax reports and exports](https://docs.stripe.com/tax/reports.md) to reconcile and remit; filing runs through Stripe (US) or Taxually (non-US).
## If jurisdictions are unknown
Dont guess which jurisdictions apply. Ask the user which states or countries they have customers in, then add a registration for each with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard.
## If the region or tax type isnt supported
Check the [supported countries list](https://docs.stripe.com/tax/supported-countries.md). If the jurisdiction isnt listed, tell the user:
- Stripe Tax doesnt support that region yet
- They can collect tax manually using `tax_rates` on the subscription or invoice instead (not alongside `automatic_tax`; you cant use both)
- For unsupported tax types (customs duties, excise taxes), Stripe Tax doesnt apply, so those are out of scope
Dont attempt to approximate using a supported region as a proxy.
@@ -1,16 +0,0 @@
# Treasury / Financial Accounts
## Table of contents
- v2 Financial Accounts API
- Legacy v1 Treasury
## v2 Financial Accounts API
For embedded financial accounts (bank accounts, account and routing numbers, money movement), use the [v2 Financial Accounts API](https://docs.stripe.com/api/v2/core/vault/financial-accounts.md) (`POST /v2/core/vault/financial_accounts`). This is required for new integrations.
For Treasury for platforms concepts and guides, see the [Treasury for platforms overview](https://docs.stripe.com/treasury/connect.md).
## Legacy v1 Treasury
Dont use the [v1 Treasury Financial Accounts API](https://docs.stripe.com/api/treasury/financial_accounts.md) (`POST /v1/treasury/financial_accounts`) for new integrations. Existing v1 integrations continue to work.
-77
View File
@@ -1,77 +0,0 @@
---
name: stripe-directory
description: >-
Use when the user wants to find businesses, software, service providers, or
partners for a specific industry, workflow, pain point, capability, or job to
be done. Also use when the agent needs to programmatically purchase or consume
a service. Use Stripe Directory to build a short relevant shortlist, even if
the user does not mention Stripe Directory explicitly.
metadata:
short-description: Find (and optionally purchase from) vendors or partners
allowed-tools:
- Bash(stripe directory *)
---
## Stripe Directory Search
Turn a vague market need into a short, relevant shortlist with `stripe directory search`. Use this even when the user never says “Stripe Directory” — any request to find vendors, tools, partners, or providers for a vertical, workflow, pain point, or job-to-be-done.
Most requests are **discovery** — find and compare services. That is the core job below. Some services are also **MPP-supported** (MPP = Machine Payment Protocol), meaning you (the agent) can pay their HTTP 402 (Payment Required) endpoint and consume them directly. When the user actually wants to *use or buy* a service, present those results and offer to purchase — see “Purchasing” at the end.
## Process
1. **Clarify only whats missing**: buyer/vertical, job-to-be-done, must-have capability, geography (only if it matters).
2. **Search iteratively**: `stripe directory search "<query>" --format json`
- Short noun phrases, one angle per query; run 1-3, then broaden/narrow on results.
- Angles to cover: vertical → workflow → pain point → adjacent. Two examples:
- services/trades: vertical (`electrician software`, `electrical contractor`) → workflow (`field service management`, `dispatch invoicing estimates`) → pain point (`job scheduling`, `quote automation`) → adjacent (`home services automation`, `contractor crm`).
- SaaS/software: vertical (`b2b saas billing`, `developer tools`) → workflow (`subscription management`, `usage-based metering`) → pain point (`failed payment recovery`, `revenue recognition`) → adjacent (`analytics dashboards`, `customer onboarding`).
- Hard constraints → filters: `--countries-supported=US`, `--has-stripe-app=true`, `--link-supported=true`, `--stripe-projects-supported=true`.
- If the user wants to *use/buy* a service, also pass `--mpp-supported` in at least one search to find results you can pay for programmatically.
- Sparse niche? Raise `--limit` and try the next `--page` before concluding its empty.
3. **Dedupe & score** using `display_name`, `description`, `url`, `username` as evidence.
- Prefer results whose description/site clearly match the target workflow.
- Prefer more trust signals over fewer: Projects provider, Link enabled, Marketplace app, Stripe Verified. For buy/use intent, also prefer MPP-supported results.
- Thin description but strong brand/domain match → keep in a weaker bucket, dont discard.
4. **Return a shortlist, not a dump** — 5-10 strong matches, grouped:
- **direct** / **adjacent** / **needs manual review**
- Each entry: name · why it matched · URL (· which query surfaced it, when useful).
- Projects providers: offer the follow-up. The JSON gives the exact commands under each results `projects.catalog_command` / `projects.install_command` (`stripe projects catalog <provider>`, `stripe projects add <provider>`).
- MPP-supported results: note theyre purchasable and include `mpp.slug` / `mpp.url`.
5. **Be honest about weak results** — if sparse or generic, say so and adjust: broaden, narrow, or try synonyms rather than padding with noise.
Always report the exact queries (and filters) you ran so the user can keep iterating.
## Purchasing (only when the user wants to buy or consume a service)
MPP-supported results are payable directly. Dont drive to purchase unprompted. When the user wants to buy, **present the full menu of payment methods and ask which theyd like to use** before doing anything:
> "Which payment method would you like to use?
>
> - **Link CLI** — Stripe-native, test mode available (recommended)
- **Tempo** — crypto wallet
- **Privy Agent Wallet CLI** — crypto wallet
- **mppx** — debug-only fallback"
Once the user picks, silently run `which <tool> 2>/dev/null` to check if its installed. If not installed, offer to install it (for example, `npm i -g @stripe/link-cli` for Link CLI) and wait for confirmation before proceeding.
**Always show the price and get explicit user approval before any money moves**; prefer a no-charge test path first.
Short version:
1. Resolve the real callable endpoint from the results `mpp.slug` / `mpp.url`. `mpp.url` is often the mpp.dev landing form (`https://mpp.dev/services#<slug>`) — resolve the raw endpoint on [mpp.dev](https://mpp.dev) if so. Read the HTTP 402 challenge to confirm the amount: `curl -s -D - -o /dev/null <endpoint_url>` (look for `WWW-Authenticate`).
2. Use the payer the user selected.
- **`link-cli`** (Stripe-native Shared Payment Token, has a test mode, no crypto wallet, US Link accounts only; `npm i -g @stripe/link-cli`): `auth login``mpp decode --challenge "<value>"` (get `network_id`) → `spend-request create --credential-type shared_payment_token --network-id <id> --amount <cents ≤50000> --context "<100+ chars>" --request-approval` (blocks for approval) → `mpp pay <endpoint_url> --spend-request-id <approved_id>`.
- **Tempo**: `tempo wallet login` / `services` / `request`.
- **Privy**: `@privy-io/agent-wallet-cli`.
- **mppx**: debug-only fallback.
Never invent results or skip the price/approval gate.
-42
View File
@@ -1,42 +0,0 @@
---
name: stripe-docs
description: >-
Use when the user or agent needs to read, search, or look up Stripe
documentation or API reference. Prefer this over curl or WebFetch for any
docs.stripe.com content.
metadata:
short-description: Read and search Stripe documentation from the terminal
allowed-tools:
- Bash(stripe docs *)
---
Use `stripe docs` instead of fetching [docs.stripe.com](https://docs.stripe.com/.md) content directly with `curl` or `WebFetch`.
- Fetches Markdown automatically
- Purpose-built for agents and terminal workflows
## Read a page by its web path
```bash
stripe docs /payments
```
## Search documentation by keyword
```bash
stripe docs search "payment intents"
```
## Look up API reference
```bash
# By resource name
stripe docs api product
# By HTTP method and path
stripe docs api GET /v1/products
# By event type
stripe docs api product.created
```
-169
View File
@@ -1,169 +0,0 @@
---
name: stripe-projects
description: >
Use when the user wants to provision infrastructure or third-party services
using Stripe Projects. Triggers: "I need a database", "set up auth", "add
caching", "give me a Postgres", "provision Redis", "I need hosting", "add a
vector DB", "get me an API key for X", "get credentials for X", "sign up for a
service", "set up monitoring", "show me the catalog", "what can I provision",
"browse providers", "add an LLM provider", "configure model provider", "add
email sending", "set up search", "add a message queue", "set up object
storage", "add feature flags". Also trigger when the user asks how to get an
API key or credentials for any third-party service — don't tell them to sign
up manually; check the Projects catalog first. Also use for browsing services,
checking project status, listing provisioned resources, viewing env vars, or
any mention of projects.dev or adding/provisioning/connecting a cloud service.
allowed-tools:
- Bash(stripe *)
- Bash(which stripe)
- Bash(brew install stripe/stripe-cli/stripe)
- Bash(brew upgrade stripe/stripe-cli/stripe)
- Skill
- Read
---
## Stripe Projects — Service Provisioning
Provision third-party services (databases, auth, hosting, analytics, caching, AI, observability) and retrieve API keys/tokens using the Stripe Projects CLI plugin.
## Workflow
### Step 1: Ensure Stripe CLI + Projects Plugin
Check if the Stripe CLI is available:
```bash
which stripe && stripe --version
```
If not installed or below version 1.40.0:
- **macOS (Homebrew):** `brew install stripe/stripe-cli/stripe` (or `brew upgrade stripe/stripe-cli/stripe`)
- **Other platforms:** Direct the user to https://docs.stripe.com/stripe-cli/install for up-to-date instructions.
Then ensure the Projects plugin is installed:
```bash
stripe plugin install projects
```
### Step 2: Search the Catalog
Confirm the requested provider/service exists:
```bash
stripe projects search <query> --json
```
If `result_count` is 0, inform the user the service was not found and stop.
If the users request is vague (for example, “I need a database”), browse the catalog to suggest options:
```bash
stripe projects catalog --json
```
### Step 3: Initialize a Project
Check if a project is already initialized:
```bash
stripe projects status --json
```
If not initialized, run a preflight check first to reveal all blockers at once:
```bash
stripe projects init --preflight --json
```
If all preflight checks pass (or the only failures are `TOS_ACCEPTANCE_REQUIRED` or `Stripe session authenticated`), proceed:
```bash
stripe projects init --accept-tos --yes
```
**Important:** `stripe projects init` installs the `stripe-projects-cli` skill locally at `.claude/skills/stripe-projects-cli`. This skill contains the full post-init command reference.
### Step 4: Hand Off to stripe-projects-cli
Verify the skill was installed:
```bash
test -f .claude/skills/stripe-projects-cli/SKILL.md && echo "OK" || echo "MISSING"
```
If `MISSING`: re-run `stripe projects init --accept-tos --yes` — the skill is bundled with the Projects plugin and installed during init.
If `OK`: use the locally-installed `stripe-projects-cli` skill (invoke using the Skill tool with name `stripe-projects-cli`) to continue the workflow — adding services, managing credentials, and configuring the project.
### Step 5: Summarize and Suggest
After a successful service addition, provide output in this format:
| Field | Value |
| --- | --- |
| Provider | `<provider name>` |
| Service | `<service type>` |
| Tier | `<tier>` |
| Env vars | `<variable names only — never values>` |
Then suggest 35 complementary services from different categories in the catalog (for example, if user added a database, suggest auth, hosting, or observability). Only reference services that actually appear in `stripe projects catalog --json` output — never fabricate commands or provider names.
## CLI as Source of Truth
The CLI manages all state under `.projects/` and generates `.env` files. Dont hand-edit these files. If you need to inspect project state, use the appropriate CLI command:
| Task | Command |
| --- | --- |
| View provisioned services | `stripe projects status --json` |
| List env var names | `stripe projects env --json` |
| Check project health | `stripe projects status --json` |
| Browse available services | `stripe projects catalog --json` |
Only inspect `.projects/` or `.env` directly if the user explicitly asks you to — the CLI is authoritative, so manual edits may be overwritten.
## Project Variables
Use project variables when the user wants to store an environment variable that doesnt come from a provisioned provider resource, such as an app URL, feature flag, or self-managed API key.
Create or update a project variable for the active environment:
```bash
stripe projects variables set <name> --env-key <ENV_KEY> --value <value>
```
A successful `variables set` syncs the active environment output file immediately. If the user doesnt provide the value, run the command without `--value` only in interactive mode so the CLI can prompt securely. Never print secret values in your response.
Bind an existing project variable to the active environment:
```bash
stripe projects env add <name> --variable --env-key <ENV_KEY>
```
Remove a variable binding from the active environment without deleting the stored variable:
```bash
stripe projects env remove <name> --variable
```
List and delete project variables:
```bash
stripe projects variables list --json
stripe projects variables delete <name> --yes
```
## Error Handling
| Error code | Cause | Recovery |
| --- | --- | --- |
| `BROWSER_AUTH_REQUIRED` | No auth session and browser needed | Tell user to run `stripe login` — you cannot fix this |
| `ACCOUNT_NOT_ELIGIBLE` | Account not onboarded for Projects | Tell user to run `stripe login` or visit https://projects.dev |
| `TOS_ACCEPTANCE_REQUIRED` | Developer or provider terms not accepted | Re-run with `--accept-tos` |
| `PROVIDER_NOT_LINKED` | Provider requires OAuth linking | Run `stripe projects link <provider>` — may open a browser |
| `PLAN_REQUIRED` | Deployable needs a plan provisioned first | Provision the plan listed in the error, then retry |
| `UNKNOWN_ERROR` | Unexpected failure | Show the full error message to the user and suggest running with `--debug` for diagnostics |
| Service not in catalog | Query returned 0 results | Inform user; suggest `stripe projects catalog --json` to browse alternatives |
| CLI not found | Stripe CLI not installed | Install using Homebrew (macOS) or follow https://docs.stripe.com/stripe-cli/install |
-185
View File
@@ -1,185 +0,0 @@
---
name: upgrade-stripe
description: Guide for upgrading Stripe API versions and SDKs
---
The latest Stripe API version is 2026-06-24.dahlia - use this version when upgrading unless the user specifies a different target version.
# Upgrading Stripe Versions
This guide covers upgrading Stripe API versions, server-side SDKs, Stripe.js, and mobile SDKs.
## Understanding Stripe API Versioning
Stripe uses date-based API versions (e.g., `2026-06-24.dahlia`, `2025-08-27.basil`, `2024-12-18.acacia`). Your accounts API version determines request/response behavior.
### Types of Changes
**Backward-Compatible Changes** (dont require code updates):
- New API resources
- New optional request parameters
- New properties in existing responses
- Changes to opaque string lengths (e.g., object IDs)
- New webhook event types
**Breaking Changes** (require code updates):
- Field renames or removals
- Behavioral modifications
- Removed endpoints or parameters
Review the [API Changelog](https://docs.stripe.com/changelog.md) for all changes between versions.
## Server-Side SDK Versioning
See [SDK Version Management](https://docs.stripe.com/sdks/set-version.md) for details.
### Dynamically-Typed Languages (Ruby, Python, PHP, Node.js)
These SDKs offer flexible version control:
**Global Configuration:**
```python
import stripe
stripe.api_version = '2026-06-24.dahlia'
```
```ruby
Stripe.api_version = '2026-06-24.dahlia'
```
```javascript
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2026-06-24.dahlia'
});
```
**Per-Request Override:**
```python
stripe.Customer.create(
email="customer@example.com",
stripe_version='2026-06-24.dahlia'
)
```
### Strongly-Typed Languages (Java, Go, .NET)
These use a fixed API version matching the SDK release date. Dont set a different API version for strongly-typed languages because response objects might not match the strong types in the SDK. Instead, update the SDK to target a new API version.
### Best Practice
Always specify the API version youre integrating against in your code instead of relying on your accounts default API version:
```javascript
// Good: Explicit version
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2026-06-24.dahlia'
});
// Avoid: Relying on account default
const stripe = require('stripe')('sk_test_xxx');
```
## Stripe.js Versioning
See [Stripe.js Versioning](https://docs.stripe.com/sdks/stripejs-versioning.md) for details.
Stripe.js uses an evergreen model with major releases (Acacia, Basil, Clover, Dahlia) on a biannual basis.
### Loading Versioned Stripe.js
**Via Script Tag:**
```html
<script src="https://js.stripe.com/dahlia/stripe.js"></script>
```
**Via npm:**
```bash
npm install @stripe/stripe-js
```
Major npm versions correspond to specific Stripe.js versions.
### API Version Pairing
Each Stripe.js version automatically pairs with its corresponding API version. For instance:
- Dahlia Stripe.js uses `2026-06-24.dahlia` API
- Acacia Stripe.js uses `2024-12-18.acacia` API
You cant override this association.
### Migrating from v3
1. Identify your current API version in code
2. Review the changelog for relevant changes
3. Consider gradually updating your API version before switching Stripe.js versions
4. Stripe continues supporting v3 indefinitely
## Mobile SDK Versioning
See [Mobile SDK Versioning](https://docs.stripe.com/sdks/mobile-sdk-versioning.md) for details.
### iOS and Android SDKs
Both platforms follow **semantic versioning** (MAJOR.MINOR.PATCH):
- **MAJOR**: Breaking API changes
- **MINOR**: New functionality (backward-compatible)
- **PATCH**: Bug fixes (backward-compatible)
New features and fixes release only on the latest major version. Upgrade regularly to access improvements.
### React Native SDK
Uses a different model (0.x.y schema):
- **Minor version changes** (x): Breaking changes AND new features
- **Patch updates** (y): Critical bug fixes only
### Backend Compatibility
All mobile SDKs work with any Stripe API version you use on your backend unless documentation specifies otherwise.
## Upgrade Checklist
1. Review the [API Changelog](https://docs.stripe.com/changelog.md) for changes between your current and target versions
2. Check [Upgrades Guide](https://docs.stripe.com/upgrades.md) for migration guidance
3. Update server-side SDK package version (e.g., `npm update stripe`, `pip install --upgrade stripe`)
4. Update the `apiVersion` parameter in your Stripe client initialization
5. Test your integration against the new API version using the `Stripe-Version` header
6. Update webhook handlers to handle new event structures
7. Update Stripe.js script tag or npm package version if needed
8. Update mobile SDK versions in your package manager if needed
9. Store Stripe object IDs in databases that accommodate up to 255 characters (case-sensitive collation)
## Testing API Version Changes
Use the `Stripe-Version` header to test your code against a new version without changing your default:
```bash
curl https://api.stripe.com/v1/customers \
-u sk_test_xxx: \
-H "Stripe-Version: 2026-06-24.dahlia"
```
Or in code:
```javascript
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2026-06-24.dahlia' // Test with new version
});
```
## Important Notes
- Your webhook listener should handle unfamiliar event types gracefully
- Test webhooks with the new version structure before upgrading
- Breaking changes are tagged by affected product areas (Payments, Billing, Connect, etc.)
- Multiple API versions coexist simultaneously, enabling staged adoption
-52
View File
@@ -1,52 +0,0 @@
# Scope Discipline Rules
These rules override any general instinct to "improve while I'm in there." Follow them on every task, no exceptions.
## Before touching any code
1. Restate the task in one sentence: what behavior must change, and what the expected outcome is.
2. Identify the smallest set of files/functions responsible for that behavior. This is your **allowed scope**. Everything else is **protected**.
3. Read the relevant code before editing it. Do not edit based on assumptions about how it probably works.
## The hard rule: ask before expanding scope
If, while working, you find that:
- a file outside your allowed scope needs to change,
- a dependency needs to be added/updated,
- a test needs modification,
- an unrelated bug is blocking you,
- or a "cleaner" implementation would touch more than the minimum,
**stop and ask me before making that change.** Explain:
- what you were trying to do,
- why the fix requires going outside the original scope,
- exactly what you want to change and where.
Wait for my answer. Do not proceed on your own judgment, even if you're confident it's correct or trivial.
This applies even to small things (renaming a variable for clarity, fixing a typo in an unrelated comment, reformatting a block you had to scroll past). If it's not required to satisfy the request, ask first.
## While editing
- Make the fewest-line, fewest-file change that correctly satisfies the request.
- Preserve existing naming, structure, patterns, and formatting. Match the codebase's existing style, don't impose your own.
- Never run project-wide formatters/linters-with-autofix/import-organizers as a side effect of a small change.
- Never touch tests except to add new ones that validate the requested behavior — and only after confirming that's in scope.
- Treat any uncommitted/staged changes already in the working tree as off-limits. Don't revert, reset, or absorb them into your edit.
## Before reporting done
Review your own diff, file by file, line by line. For anything you can't justify with "this was required by the explicit request," revert it.
Then report:
- **Files changed** — list, with a one-line reason each tied directly to the request.
- **Scope confirmation** — explicitly state: "No files, dependencies, tests, or config outside this list were modified."
- **Anything you noticed but didn't touch** — unrelated bugs, tech debt, cleanup opportunities. Mention them, don't fix them.
## If the task genuinely can't be done without expanding scope
Say so plainly, explain what would need to change and why, and wait for confirmation. Don't silently do the bigger version, and don't pretend a partial/incorrect fix is complete.
---
**Default when uncertain: don't make the change, ask instead.**
+5 -21
View File
@@ -21,11 +21,11 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00
JWT_SECRET=placeholder
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=Qwerty0012345
ADMIN_SEED_PASSWORD=placeholder
ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
@@ -33,13 +33,12 @@ CLERK_SECRET_KEY=placeholder
NODE_ENV=development
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
# Email — Gmail SMTP for local Docker development
EMAIL_PROVIDER=gmail
# Email — Resend (primary) with SMTP fallback
# Get your API key at https://resend.com/api-keys
RESEND_API_KEY=re_PLACEHOLDER
EMAIL_FROM=noreply@rentaldrivego.ma
EMAIL_FROM_NAME=RentalDriveGo
# Use a Gmail app password, not your normal Google account password.
# SMTP fallback (only used if Resend fails or is unconfigured)
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
@@ -49,18 +48,3 @@ MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo
# Manual subscription payments for local development
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true
PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean
BANK_TRANSFER_ENABLED=true
BANK_TRANSFER_ACCOUNT_NAME=RentalDriveGo SARL
BANK_TRANSFER_BANK_NAME=Local Development Bank
BANK_TRANSFER_ACCOUNT_REFERENCE=DEV-MA64-0000-0000-0000
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=true
CHECK_PAYMENT_PAYEE=RentalDriveGo SARL
CHECK_PAYMENT_DELIVERY_ADDRESS=Local development billing desk
CHECK_PAYMENT_DUE_DAYS=14
+3 -7
View File
@@ -67,10 +67,6 @@ JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
NODE_ENV=production
# Shared parent-domain cookie so sessions work across rentaldrivego.ma and
# api.rentaldrivego.ma (the admin/dashboard apps call the API cross-origin).
# Must start with a leading dot.
SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
# ── Email ─────────────────────────────────────────────────────────────────────
@@ -79,18 +75,18 @@ EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail)
# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend.
EMAIL_PROVIDER=gmail
# SMTP fallback (only used if Resend fails or is unconfigured)
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo
# ── Firebase push notifications (optional) ────────────────────────────────────
# FIREBASE_PROJECT_ID=your-firebase-project-id
# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
+1 -2
View File
@@ -31,7 +31,7 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
NEXT_PUBLIC_CARPLACE_URL=https://example.com
NEXT_PUBLIC_STOREFRONT_URL=https://example.com
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
@@ -45,7 +45,6 @@ CORS_ORIGINS=https://example.com,https://www.example.com
JWT_SECRET=placeholder
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
SESSION_COOKIE_DOMAIN=.example.com
NODE_ENV=production
# File storage
+19 -10
View File
@@ -21,9 +21,6 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
# Optional in local development. Required in production when auth spans sibling subdomains.
# Example: SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
SESSION_COOKIE_DOMAIN=
# ─── Clerk (Company employee auth) ────────────────────────────
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
@@ -34,6 +31,20 @@ NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
# ─── AmanPay (Primary payment provider) ───────────────────────
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
AMANPAY_SECRET_KEY=placeholder
AMANPAY_BASE_URL=https://api.amanpay.net
AMANPAY_WEBHOOK_SECRET=placeholder
# ─── PayPal (Secondary payment provider) ──────────────────────
# RentalDriveGo's own PayPal account (for collecting subscription fees)
PAYPAL_CLIENT_ID=your-paypal-client-id
PAYPAL_CLIENT_SECRET=placeholder
PAYPAL_BASE_URL=https://api-m.paypal.com
# Use https://api-m.sandbox.paypal.com for sandbox
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
# ─── Cloudinary (Vehicle + brand photos) ──────────────────────
CLOUDINARY_CLOUD_NAME=your-cloud-name
@@ -77,7 +88,7 @@ REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
# Public site is subdomain-based; use this for local dev:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
@@ -86,19 +97,17 @@ PUBLIC_SITE_DOMAIN=rentaldrivego.ma
DASHBOARD_URL=http://localhost:3000/dashboard
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=Qwerty0012345
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
ADMIN_SEED_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin
# Email provider: auto, resend, smtp, or gmail.
# Use a Gmail app password for Gmail SMTP, not your normal Google account password.
EMAIL_PROVIDER=gmail
# SMTP fallback (only used if Resend fails or is unconfigured)
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+172 -395
View File
@@ -1,8 +1,8 @@
name: Build & Push
name: Build & Deploy
on:
push:
branches: [fix_branch]
branches: [develop]
workflow_dispatch:
concurrency:
@@ -15,199 +15,68 @@ env:
GIT_SSL_NO_VERIFY: "true"
REGISTRY_HOST: 192.168.3.80
DEPLOY_REGISTRY_HOST: 10.0.0.4
DEPLOY_SSH_HOST: 10.0.0.1
DOCKERFILE_PATH: Dockerfile.production
DOCKER_PLATFORM: linux/amd64
DEPLOY_ROOT: /opt/rentaldrivego
jobs:
pipeline-tests:
name: Pipeline Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: rentaldrivego_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:password@postgres:5432/rentaldrivego_test
REDIS_URL: redis://redis:6379
NODE_ENV: test
JWT_SECRET: test-secret
JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps:
- name: Checkout repository
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
CHECKOUT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
if ! command -v git >/dev/null 2>&1; then
echo "::error::git must be available in the runner image"
exit 1
fi
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
echo "::error::Missing repository checkout context"
exit 1
fi
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
if [ ! -d .git ]; then
git init
git remote add origin "$REPOSITORY_URL"
fi
git config --global --add safe.directory "$WORKSPACE"
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
else
git fetch --no-tags --depth=1 origin "$SHA"
fi
git checkout --force FETCH_HEAD
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- name: Generate database client
run: npm run db:generate
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Apply test database migrations
run: npm run db:deploy
- name: Synchronize test database schema
run: npx prisma db push --schema packages/database/prisma/schema.prisma
- name: Integration tests
run: npm run test:integration
build-image:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: pipeline-tests
outputs:
image_repository: ${{ steps.image-meta.outputs.repository }}
docker_image: ${{ steps.image-meta.outputs.full }}
steps:
- name: Checkout repository
- name: Check out repository
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
CHECKOUT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
if ! command -v git >/dev/null 2>&1; then
echo "::error::git must be available in the runner image; this workflow cannot install git while runner DNS is unavailable."
exit 1
fi
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
echo "::error::Missing repository checkout context"
exit 1
fi
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
if [ ! -d .git ]; then
git init
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
git config --global --add safe.directory "$WORKSPACE"
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
else
git fetch --no-tags --depth=1 origin "$SHA"
git rev-parse --short HEAD
- name: Ensure Docker CLI is available
run: |
if ! command -v docker >/dev/null 2>&1; then
echo "::error::Docker CLI is not available in this Gitea runner image."
echo "::error::Configure the runner label used by this job to an image that already includes Docker CLI, for example ubuntu-latest:docker://catthehacker/ubuntu:act-latest."
echo "::error::The current runner maps ubuntu-latest to node:20-bookworm, and this job cannot install docker.io because the job container has no external DNS."
exit 1
fi
git checkout --force FETCH_HEAD
docker --version
docker info >/dev/null
- name: Set up Docker Buildx
shell: bash
run: |
set -euo pipefail
docker buildx version
printf '[registry."%s"]\n insecure = true\n' "$REGISTRY_HOST" > /tmp/buildkitd.toml
if ! docker buildx inspect rentaldrivego-builder >/dev/null 2>&1; then
docker buildx create \
--name rentaldrivego-builder \
--driver docker-container \
--config /tmp/buildkitd.toml \
--use
else
docker buildx use rentaldrivego-builder
fi
docker buildx inspect --bootstrap
- name: Docker image metadata
id: image-meta
@@ -215,23 +84,16 @@ jobs:
REPO="${{ gitea.repository }}"
TAG="${{ gitea.sha }}"
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
- name: Check Docker registry credentials
id: registry-check
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Registry credentials secrets not set — configure REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN; push will be skipped"
echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped"
echo "available=false" >> "$GITHUB_OUTPUT"
fi
@@ -245,243 +107,158 @@ jobs:
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
run: |
validate_url() {
name="$1"
value="$2"
for variable in \
NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_HOMEPAGE_URL \
NEXT_PUBLIC_CARPLACE_URL \
NEXT_PUBLIC_DASHBOARD_URL \
NEXT_PUBLIC_ADMIN_URL \
SITE_ORIGIN
do
value="${!variable}"
if [ -z "$value" ]; then
echo "::error::$name is missing — add it to Gitea Actions secrets"
echo "::error::$variable is missing — add it to Gitea Actions secrets"
exit 1
fi
case "$value" in
http://*|https://*) ;;
*)
echo "::error::$name must be an absolute URL (got: $value)"
echo "::error::$variable must be an absolute URL (got: $value)"
exit 1
;;
esac
}
done
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
- name: Log in to Gitea Container Registry
if: steps.registry-check.outputs.available == 'true'
shell: bash
run: |
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login "$REGISTRY_HOST" \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
validate_url NEXT_PUBLIC_HOMEPAGE_URL "$NEXT_PUBLIC_HOMEPAGE_URL"
validate_url NEXT_PUBLIC_CARPLACE_URL "$NEXT_PUBLIC_CARPLACE_URL"
validate_url NEXT_PUBLIC_DASHBOARD_URL "$NEXT_PUBLIC_DASHBOARD_URL"
validate_url NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL"
validate_url SITE_ORIGIN "$SITE_ORIGIN"
- name: Check remote build credentials
id: check-build-host
- name: Build and push
shell: bash
env:
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
BUILDKIT_NO_CLIENT_TOKEN: "true"
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host"
exit 1
fi
if [ -z "$VPS_HOST" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "::error::VPS_USER and either VPS_IP or DEPLOY_SSH_HOST are required to build on the remote Docker host"
exit 1
set -euo pipefail
OUTPUT_ARG="--load"
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
OUTPUT_ARG="--push"
fi
- name: Check SSH tools
docker buildx build \
--file "$DOCKERFILE_PATH" \
--platform "$DOCKER_PLATFORM" \
--tag "${{ steps.image-meta.outputs.full }}" \
--tag "${{ steps.image-meta.outputs.latest }}" \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
--build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
--build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
--build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
"$OUTPUT_ARG" \
.
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: [build-image]
env:
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
steps:
- name: Check out repository
shell: bash
run: |
if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then
echo "::error::ssh, ssh-keygen, and tar must be available in the runner image; this workflow cannot install packages while runner DNS is unavailable."
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
git rev-parse --short HEAD
- name: Check deploy credentials
id: check-creds
run: |
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
[ -z "${{ secrets.VPS_IP }}" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "VPS secrets not fully configured — skipping deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Install SSH client
if: steps.check-creds.outputs.skip == 'false'
run: |
if ! command -v ssh >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then
echo "::error::OpenSSH client tools are not available in this Gitea runner image."
echo "::error::Use a runner image that already includes ssh and scp; installing packages from apt is not reliable because the job container has no external DNS."
exit 1
fi
ssh -V
- name: Set up SSH key
env:
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
if: steps.check-creds.outputs.skip == 'false'
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
echo "Using SSH host $VPS_HOST"
mkdir -p ~/.ssh && chmod 700 ~/.ssh
if [ -n "$VPS_SSH_KEY_B64" ]; then
if ! printf '%s' "$VPS_SSH_KEY_B64" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_rsa 2>/tmp/vps_ssh_key_decode.err; then
if [ -n "$VPS_SSH_KEY" ]; then
echo "::warning::VPS_SSH_KEY_B64 is not valid base64; using VPS_SSH_KEY instead"
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
else
echo "::error::VPS_SSH_KEY_B64 is not valid base64. Store a base64-encoded private key there, or set VPS_SSH_KEY to the raw private key."
exit 1
fi
fi
else
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
fi
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
key_header="$(head -n 1 ~/.ssh/id_rsa || true)"
key_size="$(wc -c < ~/.ssh/id_rsa | tr -d ' ')"
case "$key_header" in
"-----BEGIN "*PRIVATE*" KEY-----") ;;
ssh-*|"ecdsa-"*|"sk-"*)
echo "::error::Decoded SSH key looks like a public key, not a private key. Encode the private key file, not the .pub file."
exit 1
;;
*)
echo "::error::Decoded SSH key does not start with a private key header. Decoded byte count: $key_size."
exit 1
;;
esac
if ! keygen_error="$(ssh-keygen -y -f ~/.ssh/id_rsa 2>&1 >/dev/null)"; then
echo "::error::SSH private key is not readable by ssh-keygen: $keygen_error. Use an unencrypted private key or configure a CI-specific deploy key."
exit 1
fi
ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub
key_fingerprint="$(ssh-keygen -lf ~/.ssh/id_rsa.pub | awk '{print $2}')"
echo "Loaded deploy key fingerprint: $key_fingerprint"
echo "Deploy public key: $(cat ~/.ssh/id_rsa.pub)"
touch ~/.ssh/known_hosts
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
chmod 644 ~/.ssh/known_hosts
- name: Test SSH authentication
env:
VPS_HOST: ${{ secrets.VPS_IP }}
- name: Sync deployment assets
if: steps.check-creds.outputs.skip == 'false'
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')"
echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint"
if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then
echo "::error::SSH authentication failed. Verify VPS_USER matches the account that has deploy key fingerprint $key_fingerprint in ~/.ssh/authorized_keys on $VPS_HOST."
exit 1
fi
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'"
scp docker-compose.production.yml \
docker-compose.portainer.production.yml \
docker-compose.registry.production.yml \
docker-compose.registry.local.yml \
traefik.yaml \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/"
scp scripts/docker-prod-common.sh \
scripts/docker-prod-deploy.sh \
scripts/docker-prod-up-registry.sh \
scripts/docker-registry-local-up.sh \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
scp docker/pgmanage/override.py \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
- name: Sync build context to VPS
env:
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
- name: Run deploy script on VPS
if: steps.check-creds.outputs.skip == 'false'
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
"rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'"
tar \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='dist' \
--exclude='coverage' \
-czf - . | ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
"tar -xzf - -C '$REMOTE_BUILD_DIR'"
- name: Build and push on VPS
env:
PUSH_IMAGE: ${{ steps.registry-check.outputs.available == 'true' }}
IMAGE_FULL: ${{ steps.image-meta.outputs.full }}
IMAGE_LATEST: ${{ steps.image-meta.outputs.latest }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')"
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
set -e
cd '$REMOTE_BUILD_DIR'
if ! command -v docker >/dev/null 2>&1; then
echo 'Docker must be installed on the VPS build host' >&2
exit 1
fi
if [ '$PUSH_IMAGE' = 'true' ]; then
printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d | docker login '$DEPLOY_REGISTRY_HOST' \
--username '$REGISTRY_USERNAME' \
--password-stdin
fi
docker build \
--file '$DOCKERFILE_PATH' \
--platform '$DOCKER_PLATFORM' \
--tag '$IMAGE_FULL' \
--tag '$IMAGE_LATEST' \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \
.
if [ '$PUSH_IMAGE' = 'true' ]; then
docker push '$IMAGE_FULL'
docker push '$IMAGE_LATEST'
fi
"
- name: Publish deployment files to VPS
env:
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }}
IMAGE_TAG: ${{ gitea.sha }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
set -e
cd '$REMOTE_BUILD_DIR'
mkdir -p '$DEPLOY_ROOT/scripts'
cp docker-compose.production.yml '$DEPLOY_ROOT/docker-compose.production.yml'
cp docker-compose.portainer.production.yml '$DEPLOY_ROOT/docker-compose.portainer.production.yml'
cp docker-compose.registry.production.yml '$DEPLOY_ROOT/docker-compose.registry.production.yml'
cp traefik.yaml '$DEPLOY_ROOT/traefik.yaml'
cp scripts/apply-env-secret-overrides.sh '$DEPLOY_ROOT/scripts/apply-env-secret-overrides.sh'
cp scripts/describe-env-values.sh '$DEPLOY_ROOT/scripts/describe-env-values.sh'
cp scripts/docker-prod-*.sh '$DEPLOY_ROOT/scripts/'
cp scripts/preserve-env-values.sh '$DEPLOY_ROOT/scripts/preserve-env-values.sh'
chmod +x '$DEPLOY_ROOT/scripts/'*.sh
printf '%s\n' \
'APP_IMAGE=$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
'IMAGE_TAG=$IMAGE_TAG' \
'REGISTRY_HOST=$DEPLOY_REGISTRY_HOST' \
> '$DEPLOY_ROOT/release.env'
chmod 600 '$DEPLOY_ROOT/release.env'
echo 'Deployment files published to $DEPLOY_ROOT. Production was not deployed.'
cd '$DEPLOY_ROOT'
APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
APP_VERSION='${{ gitea.sha }}' \
IMAGE_TAG='${{ gitea.sha }}' \
REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
bash scripts/docker-prod-deploy.sh
"
+234 -247
View File
@@ -2,9 +2,9 @@ name: Test
on:
push:
branches: ["**"]
branches: [develop]
pull_request:
branches: ["**"]
branches: [develop]
concurrency:
group: test-${{ gitea.ref }}
@@ -17,61 +17,60 @@ env:
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
NPM_CONFIG_INCLUDE: "optional"
jobs:
type-check:
name: Type Check (all packages)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run db:generate
- run: npm run type-check
@@ -80,55 +79,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run db:generate
- run: npm run test:api
@@ -137,55 +134,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run build --workspace @rentaldrivego/types
- run: npm run test:homepage
@@ -194,55 +189,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run build --workspace @rentaldrivego/types
- run: npm run test:carplace
@@ -251,55 +244,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run test:admin
dashboard-tests:
@@ -307,55 +298,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run build --workspace @rentaldrivego/types
- run: npm run test:dashboard
@@ -390,55 +379,53 @@ jobs:
JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
npm ci --include=optional && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run db:generate
- run: npm run db:deploy
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
+8 -26
View File
@@ -12,7 +12,7 @@ variables:
# ====================================
# TEST STAGE
# Runs on every branch push and merge request
# Runs on every push and merge request
# ====================================
api_tests:
@@ -26,19 +26,19 @@ api_tests:
- npm run test:api
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
carplace_tests:
storefront_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run build --workspace @rentaldrivego/types
script:
- npm run test:carplace
- npm run test:storefront
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
admin_tests:
stage: test
@@ -49,7 +49,7 @@ admin_tests:
- npm run test:admin
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
dashboard_tests:
stage: test
@@ -61,18 +61,7 @@ dashboard_tests:
- npm run test:dashboard
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
homepage_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
script:
- npm run test:homepage
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
integration_tests:
stage: test
@@ -111,7 +100,7 @@ integration_tests:
- npm run test:api:integration
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
# ====================================
# BUILD STAGE
@@ -120,13 +109,6 @@ integration_tests:
build_image:
stage: build
image: docker:24
needs:
- api_tests
- carplace_tests
- admin_tests
- dashboard_tests
- homepage_tests
- integration_tests
services:
- name: docker:24-dind
alias: docker
-40
View File
@@ -1,40 +0,0 @@
{
"$schema": "https://app.kilo.ai/config.json",
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"]
},
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"${workspaceFolder}"
]
},
"gitea": {
"command": "npx",
"args": ["-y", "gitea-mcp"],
"env": {
"GITEA_HOST": "https://192.168.3.80",
"GITEA_ACCESS_TOKEN": "d9eb80ffe28f6e4e3ad5d5a032ca9c5f93ea5414"
}
},
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
},
"fetch": {
"command": "npx",
"args": ["-y", "fetch-mcp"]
},
"git": {
"command": "npx",
"args": ["-y", "git-mcp"],
"env": {
"GIT_DEFAULT_PATH": "${workspaceFolder}"
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"servers": {}
}
View File
@@ -7,7 +7,7 @@ The Phase 16 visual system has been applied at source level to the legacy applic
- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy archive.
- `admin`: migrated to the Phase 16 token palette and component treatment; navigation was rebuilt as a responsive, accessible application shell.
- `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy.
- `carplace`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
- `storefront`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
## Design rules applied
@@ -29,7 +29,7 @@ The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence an
- `admin/src/styles/phase16-tokens.css`
- `dashboard/src/styles/phase16-tokens.css`
- `carplace/src/styles/phase16-tokens.css`
- `storefront/src/styles/phase16-tokens.css`
- `scripts/validate-phase16-design.mjs`
- `DESIGN_MIGRATION_REPORT.md`
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
@@ -7,14 +7,14 @@
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages.
4. Updated sign-in, create-account, forgot-password, reset-password, verify-email, onboarding, and invitation pages to use the public component API.
5. Preserved legacy component paths through compatibility exports.
6. Moved carplace navbar/footer assembly out of the Next.js route layout and into reusable public components.
6. Moved storefront navbar/footer assembly out of the Next.js route layout and into reusable public components.
7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
8. Added focused tests and implementation documentation.
## Deliberately unchanged
- Authentication requests and API endpoints
- Redirect behavior between carplace, dashboard, and admin applications
- Redirect behavior between storefront, dashboard, and admin applications
- Language and theme persistence
- Embedded workspace behavior
- Authenticated dashboard and admin navigation
@@ -25,7 +25,7 @@
- Verified all relative and `@/` local imports resolve.
- Verified every `use client` directive remains the first statement.
- Verified sign-in and create-account import and render `PublicPageLayout`.
- Verified the carplace route layout no longer assembles navbar/footer inline.
- Verified the storefront route layout no longer assembles navbar/footer inline.
- Verified embedded password-recovery navigation preserves `embedded=1`.
## Validation limitation
+7 -7
View File
@@ -11,7 +11,7 @@
"homepage": "retained as embedded Phase 16 source of truth",
"admin": "migrated",
"dashboard": "migrated",
"carplace": "migrated",
"storefront": "migrated",
"api": "unchanged"
},
"validation": {
@@ -37,37 +37,37 @@
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
},
{
"path": "carplace/src/app/layout.tsx",
"path": "storefront/src/app/layout.tsx",
"status": "modified",
"bytes": 1833,
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
},
{
"path": "carplace/src/app/globals.css",
"path": "storefront/src/app/globals.css",
"status": "modified",
"bytes": 7525,
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
},
{
"path": "carplace/src/components/CarplaceFooter.tsx",
"path": "storefront/src/components/StorefrontFooter.tsx",
"status": "modified",
"bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
},
{
"path": "carplace/src/components/CarplaceHeader.tsx",
"path": "storefront/src/components/StorefrontHeader.tsx",
"status": "modified",
"bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
},
{
"path": "carplace/src/styles/phase16-tokens.css",
"path": "storefront/src/styles/phase16-tokens.css",
"status": "added",
"bytes": 4214,
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
},
{
"path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"status": "modified",
"bytes": 13979,
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
+1 -1
View File
@@ -7,7 +7,7 @@
],
"includedApplications": [
"homepage",
"carplace",
"storefront",
"dashboard",
"admin",
"api"
@@ -13,7 +13,7 @@ The dashboard and admin applications now use the same visual language as the Pha
The previous navbar/footer refactor archive omitted the `homepage` and `api` applications. This package was rebuilt from the complete Phase 16 archive, then overlaid with the public navbar/footer refactor before the operational UI migration. The final repository contains:
- `homepage`
- `carplace`
- `storefront`
- `dashboard`
- `admin`
- `api`
@@ -17,9 +17,9 @@
| Light/dark and Arabic RTL token mappings present | Passed |
| Reduced-motion and forced-colors rules present | Passed |
| Shared sign-in/create-account public layout retained | Passed |
| Shared carplace navbar/footer components retained | Passed |
| Shared storefront navbar/footer components retained | Passed |
| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches |
| Complete app directories retained | `homepage`, `carplace`, `dashboard`, `admin`, `api` |
| Complete app directories retained | `homepage`, `storefront`, `dashboard`, `admin`, `api` |
## Reproducible design gate
+160
View File
@@ -0,0 +1,160 @@
# Security Hardening Application Report
Generated: 2026-06-09
Project: RentalDriveGo / Car Management System
Input archive: `/mnt/data/car_management_system_plan_applied(1).zip`
Plan applied: `/mnt/data/SECURITY_HARDENING_IMPLEMENTATION_PLAN(1).md`
## Executive result
The uploaded project was inspected and the security hardening plan was applied as far as safely possible inside the source archive. The archive already contained a broad previous hardening pass. I did not blindly trust that state, because that is how software becomes an expensive apology letter. I re-audited the implementation against the plan and applied additional corrections where the code still contradicted the target security model.
The final output includes a patched project archive, this report, a changed-file list, and an incremental diff for the additional changes made during this pass.
## What was already present in the uploaded project
The project already contained many of the plan-aligned building blocks:
- Centralized JWT helpers for actor tokens.
- HttpOnly session cookie helpers for admin, employee, and renter sessions.
- Company authorization policy helpers.
- Admin 2FA and fresh-2FA enforcement middleware.
- Public booking access-token helpers.
- Webhook idempotency helpers.
- Hashed `CompanyApiKey` model and migration.
- `ReservationPublicAccess` model and migration.
- `WebhookEvent` model and migration.
- Upload validation helpers and public/private storage separation.
- Request ID and sanitized error response middleware.
- Production Docker/Traefik hardening, including Redis authentication and `x-middleware-subrequest` blocking.
- Static security scan script and CI security gates.
That base work was useful, but it had a few security and test-consistency gaps.
## Additional changes applied in this pass
### 1. Removed legacy plaintext company API key storage
The plan requires company API keys to be hash-only. The code had introduced `CompanyApiKey`, but the legacy `Company.apiKey` field still existed in the Prisma schema and middleware still allowed a fallback lookup against that plaintext field when `ALLOW_LEGACY_COMPANY_API_KEYS=true`.
Changes applied:
- Removed `Company.apiKey` from `packages/database/prisma/schema.prisma`.
- Removed `apiKey` from the local `Company` TypeScript interface in `packages/database/src/index.ts` and `packages/database/src/index.d.ts`.
- Removed the legacy plaintext fallback path from `apps/api/src/middleware/requireApiKey.ts`.
- Added migration `20260609233000_drop_legacy_company_api_key` to drop the old column and unique index.
- Rewrote `requireApiKey` tests around prefix lookup, hash comparison, revocation, and `lastUsedAt` updates.
Security effect: raw company API keys are no longer accepted through the legacy company column and are no longer represented in the current Prisma schema.
### 2. Centralized Socket.io token verification
The main API process still verified Socket.io auth tokens directly with `jwt.verify(token, JWT_SECRET)` and did not enforce issuer, audience, actor type, or allowed algorithm. This contradicted the session-authentication phase of the plan.
Changes applied:
- Added `verifyAnyActorToken()` to `apps/api/src/security/tokens.ts`.
- Updated `apps/api/src/index.ts` to use centralized actor-token verification for Socket.io authentication.
- Removed the direct `jsonwebtoken` import from the main API entrypoint.
Security effect: Socket.io no longer accepts tokens that bypass the centralized actor-token constraints.
### 3. Hardened employee password-reset JWT verification
Employee password-reset tokens were signed and verified directly with the JWT secret and no issuer, audience, or algorithm constraints.
Changes applied:
- Added explicit `HS256` signing for employee password-reset tokens.
- Added issuer `rentaldrivego-api`.
- Added audience `employee_password_reset`.
- Added matching verification constraints.
Security effect: reset tokens now reject wrong issuer, wrong audience, or wrong algorithm instead of relying on a bare shared-secret verification.
### 4. Repaired test fixtures and middleware tests
Some tests still expected pre-hardening behavior. That is a bad smell: tests defending old weaknesses are basically tiny lobbyists for future incidents.
Changes applied:
- Updated API-key middleware tests to validate hashed-key behavior instead of plaintext `Company.apiKey` lookup.
- Updated auth middleware tests to match the centralized token verifier behavior.
- Updated integration test helper token generation to use `signActorToken()` so generated test tokens include issuer and audience.
Security effect: future test runs are less likely to push developers back toward weaker auth behavior.
## Verification performed in this sandbox
| Check | Result | Notes |
|---|---:|---|
| Static security scan | PASS | `npm run security:static` completed successfully. |
| Critical production dependency audit | PASS | `npm audit --package-lock-only --omit=dev --audit-level=critical` exited successfully. |
| JSON syntax check | PASS | Root and app `package.json` files and lockfile parsed successfully. |
| Shell syntax check | PASS | Shell scripts and production entrypoint parsed with `bash -n`. |
| YAML parse check | PASS | `docker-compose.production.yml` and `.gitlab-ci.yml` parsed successfully. |
| Node script syntax | PASS | `scripts/security-static-check.mjs` passed `node --check`. |
| Full dependency install | NOT COMPLETED | `npm ci --ignore-scripts --prefer-offline` could not complete in this sandbox. |
| Full type-check/test/build | NOT RUN | Requires dependencies to be installed. Run in CI or a normal development environment. |
| Docker compose config/render | NOT RUN | Docker is unavailable in this sandbox. |
| Prisma generate/migrate | NOT RUN | Requires dependency installation and a normal Prisma/DB environment. |
## Dependency audit note
The critical audit gate passes. The audit still reports moderate findings involving `postcss` through Next.js and `uuid` through Firebase/cron-related dependency chains. The lockfiles suggested fixes require forced or breaking upgrades, so I did not casually smash the dependency graph with a hammer and call the mess “security.” Those should be handled in a controlled dependency-upgrade ticket with full frontend and notification regression testing.
## Files changed by this pass
- `apps/api/src/index.ts`
- `apps/api/src/middleware/requireApiKey.ts`
- `apps/api/src/middleware/requireApiKey.test.ts`
- `apps/api/src/middleware/requireCompanyAuth.test.ts`
- `apps/api/src/middleware/requireRenterAuth.test.ts`
- `apps/api/src/modules/auth/auth.employee.service.ts`
- `apps/api/src/security/tokens.ts`
- `apps/api/src/tests/helpers/fixtures.ts`
- `packages/database/prisma/schema.prisma`
- `packages/database/prisma/migrations/20260609233000_drop_legacy_company_api_key/migration.sql`
- `packages/database/src/index.ts`
- `packages/database/src/index.d.ts`
See also:
- `security_hardening_incremental.diff`
- `security_hardening_changed_files.txt`
## Phase status against the hardening plan
| Phase | Status | Evidence / caveat |
|---|---:|---|
| Phase 0: Emergency stabilization | Partial / needs operator action | Static scan passes, placeholders are present, but real production secret rotation cannot be performed inside the archive. |
| Phase 1: Sessions and authentication | Substantially applied | HttpOnly session helpers and centralized actor JWT verification exist; Socket.io and reset-token gaps were corrected in this pass. |
| Phase 2: Authorization and tenant isolation | Substantially applied | Company policy middleware exists; tenant-safe patterns are present in many modules. Full proof requires test suite and code review across all repository methods. |
| Phase 3: Public booking privacy | Applied at source level | Public access token model/helper and safe public booking flow are present. Must be verified with integration tests. |
| Phase 4: Admin hardening | Applied at source level | Mandatory 2FA and fresh-2FA middleware are present. Enrollment and recovery-code workflows still need production validation. |
| Phase 5: API key hardening | Strengthened in this pass | Legacy plaintext company API key storage/fallback removed; hash-only `CompanyApiKey` path remains. |
| Phase 6: Payments and webhooks | Applied at source level | Raw-body webhook handling and idempotency helpers are present. Must be verified against provider test events. |
| Phase 7: Upload and storage hardening | Applied at source level | Magic-byte validation and public/private storage split are present. Must be verified with upload abuse tests. |
| Phase 8: Rate limiting, errors, browser security | Applied at source level | Request IDs, sanitized errors, rate limit middleware, and security headers are present. Must be verified in deployed environment. |
| Phase 9: Deployment hardening | Applied at config level | Redis auth, non-public DB tooling, private networks, and Traefik header blocking are present. Must be verified on the real host. |
| Phase 10: Observability, auditability, jobs | Partial | Logging/audit hooks exist, but queue migration and operational observability need dedicated validation. |
| CI/CD security gates | Applied at config level | Security scan and critical audit gates exist; full CI must run outside this sandbox. |
## Required follow-up before launch
1. Rotate all real production secrets and invalidate old sessions/API keys where appropriate.
2. Run `npm ci` in CI or a normal development environment.
3. Run `npm run db:generate` and apply the new migration after backup.
4. Run full type-check, unit tests, integration tests, e2e tests, and builds.
5. Run payment-provider webhook test events using real sandbox provider signatures.
6. Verify public/private storage behavior with real uploaded files.
7. Verify Redis and PostgreSQL are not externally reachable from the production host.
8. Run container image build and Trivy scan.
9. Confirm admin 2FA enrollment and fresh-2FA gates before enabling privileged admin actions in production.
10. Document any deferrals with owner, risk acceptance, compensating control, deadline, and ticket number.
## Launch recommendation
Do not launch publicly yet based only on the patched archive. The source now better matches the plan, and the critical static/audit checks pass, but the hard launch gate still depends on full CI, Prisma migration validation, deployment verification, and production secret rotation.
The patched archive is suitable for the next CI/staging pass. Treat it as implementation-ready source, not production clearance. Production clearance comes from reproducible evidence, not vibes in a ZIP file.
@@ -0,0 +1,255 @@
# Security Hardening Leftover Application Report
Project: RentalDriveGo / Car Management System
Input archive: `car_management_system_hardened_applied.zip`
Output archive: `car_management_system_leftover_applied.zip`
Date: 2026-06-09
## Executive Summary
This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model.
This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements.
## Applied Changes
### 1. Removed remaining browser-side employee token assumptions
Changed files:
- `apps/dashboard/src/lib/api.ts`
- `apps/dashboard/src/components/layout/TopBar.tsx`
- `apps/dashboard/src/components/layout/Sidebar.tsx`
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`
What changed:
- Removed dashboard use of employee auth tokens from `localStorage`.
- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies.
- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens.
- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`.
- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value.
Security effect:
- Reduces script-readable authentication exposure.
- Aligns the dashboard with the intended HttpOnly session-cookie model.
- Prevents UI code from treating a readable JWT as the authority for employee identity.
### 2. Added Socket.io HttpOnly-cookie session support
Changed file:
- `apps/api/src/index.ts`
What changed:
- Added Socket.io session-token extraction from HttpOnly cookies.
- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies.
- Reused centralized actor-token verification.
Security effect:
- Real-time dashboard connections no longer require JavaScript-readable employee tokens.
- Socket authentication now follows the same actor-token validation path used elsewhere.
### 3. Added app-layer `x-middleware-subrequest` blocking
Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path.
Security effect:
- Adds defense in depth beyond reverse-proxy filtering.
- Prevents the project from depending on a single infrastructure control for this bypass class.
### 4. Hardened admin/browser fetch behavior
Changed files include:
- `apps/admin/src/lib/api.ts`
- `apps/admin/src/app/dashboard/admin-users/page.tsx`
- `apps/admin/src/app/dashboard/renters/page.tsx`
- `apps/admin/src/app/dashboard/companies/[id]/page.tsx`
- `apps/admin/src/app/dashboard/containers/page.tsx`
- `apps/admin/src/app/dashboard/pricing/page.tsx`
- `apps/admin/src/app/forgot-password/page.tsx`
- `apps/admin/src/app/reset-password/page.tsx`
What changed:
- Admin API wrapper uses `credentials: 'include'`.
- Manual admin fetch calls now include credentials where they directly call the admin API.
- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers.
Security effect:
- Admin browser requests now consistently rely on the HttpOnly admin session cookie.
- Removes misleading bearer-token scaffolding from the admin UI.
### 5. Improved actor-aware rate limiting
Changed files:
- `apps/api/src/middleware/rateLimiter.ts`
- `apps/api/src/app.ts`
What changed:
- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens.
- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys.
- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter.
Security effect:
- Authenticated traffic is limited by actor identity instead of only coarse IP data.
- Login and admin-auth abuse get stricter protection.
- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment.
### 6. Added admin 2FA recovery-code backend workflow
Changed files:
- `packages/database/prisma/schema.prisma`
- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql`
- `apps/api/src/modules/admin/admin.repo.ts`
- `apps/api/src/modules/admin/admin.service.ts`
- `apps/api/src/modules/admin/admin.schemas.ts`
- `apps/api/src/modules/admin/admin.routes.ts`
What changed:
- Added `AdminRecoveryCode` model.
- Recovery codes are stored as bcrypt hashes, not plaintext.
- TOTP enrollment now issues one-time recovery codes.
- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA.
- Login can consume a valid unused recovery code when TOTP is enabled.
- Recovery-code issuance and use are audited.
Security effect:
- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext.
- Preserves one-time-use semantics.
- Adds auditability for recovery-code lifecycle events.
Limitation:
- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side.
### 7. Strengthened static scanning for auth-token regressions
Changed file:
- `scripts/security-static-check.mjs`
What changed:
- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`.
- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions.
Security effect:
- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens.
### 8. Updated documentation to match the hardened model
Changed files:
- `apps/dashboard/README.md`
- `memory/project_auth_architecture.md`
- `docs/project-design/COOKIE_POLICY.md`
- `apps/api/src/swagger/openapi.ts`
What changed:
- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording.
- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts.
- Updated cookie policy references from legacy `employee_token` wording to `employee_session`.
Security effect:
- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern.
## Validation Performed
The following checks were run successfully in the available environment:
```bash
npm run security:static
node --check scripts/security-static-check.mjs
# JSON parse validation for package.json and package-lock.json files
# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml
bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh
# TypeScript/TSX syntax transpile check over 507 source files
npm audit --package-lock-only --omit=dev --audit-level=critical
```
Results:
- Security static check: passed.
- Static-check script syntax: passed.
- Package JSON validation: passed.
- YAML validation: passed.
- Shell syntax validation: passed.
- TypeScript/TSX syntax transpile validation: passed.
- Critical production dependency audit: passed.
Audit note:
- `npm audit --audit-level=critical` exited successfully.
- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass.
## Not Fully Verified in This Environment
The following items require a real development/CI/deployment environment:
- `npm ci` from a clean checkout.
- Full workspace typecheck.
- Full unit, integration, security, and e2e test suites.
- Prisma client generation.
- Applying the new database migration to a real database.
- Database backup/restore verification.
- Docker image build and runtime validation.
- Container scanning with Trivy or equivalent.
- Live reverse-proxy validation for `x-middleware-subrequest` blocking.
- Redis-backed distributed rate-limit validation across multiple API containers.
- Provider webhook sandbox tests.
- Live admin 2FA recovery-code UX verification.
## Remaining Launch-Gate Work
These are still not things source edits can prove by themselves:
1. Rotate all real production secrets.
2. Confirm no real secrets exist in repository history or image layers.
3. Apply and verify the new `admin_recovery_codes` migration.
4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan.
5. Confirm Redis and PostgreSQL are private in production.
6. Confirm DB management tools are not publicly reachable.
7. Confirm production containers run non-root with reduced capabilities and resource limits.
8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads.
9. Confirm private files are inaccessible through static routes in the deployed environment.
10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely.
## Changed Files
See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff.
## Final Assessment
This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation.
However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard.
+1 -1
View File
@@ -10,7 +10,7 @@ const ADMIN_BASE_PATH = '/admin'
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix], frameSources: ['blob:'] })
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
const nextConfig = {
basePath: ADMIN_BASE_PATH,
@@ -9,7 +9,6 @@ interface AdminUser {
lastName: string
email: string
role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean
createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[]
@@ -22,7 +21,6 @@ const EMPTY_FORM = {
email: '',
password: '',
role: 'SUPPORT',
preferredLocale: 'en',
isActive: true,
}
@@ -70,7 +68,6 @@ export default function AdminUsersPage() {
email: admin.email,
password: '',
role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive,
})
setError(null)
@@ -95,7 +92,6 @@ export default function AdminUsersPage() {
lastName: form.lastName,
email: form.email,
role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive,
...(form.password ? { password: form.password } : {}),
}
@@ -210,7 +206,6 @@ export default function AdminUsersPage() {
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
<input
required
maxLength={50}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
@@ -220,7 +215,6 @@ export default function AdminUsersPage() {
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
<input
required
maxLength={50}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
@@ -232,7 +226,6 @@ export default function AdminUsersPage() {
<input
type="email"
required
maxLength={254}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
@@ -247,7 +240,6 @@ export default function AdminUsersPage() {
type={showPassword ? 'text' : 'password'}
required={!editingAdminId}
minLength={editingAdminId ? undefined : 8}
maxLength={128}
className="w-full px-3 py-2 pr-10 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
@@ -281,18 +273,6 @@ export default function AdminUsersPage() {
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.preferredLocale}
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select
File diff suppressed because it is too large Load Diff
@@ -80,6 +80,7 @@ interface CompanyDetail {
terms: string
fuelPolicyType: string
lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean
showTax: boolean
} | null
@@ -179,6 +180,7 @@ interface FormState {
terms: string
fuelPolicyType: string
lateFeePerHour: string
taxRate: string
signatureRequired: boolean
showTax: boolean
}
@@ -203,20 +205,8 @@ const STATUS_COLORS: Record<string, string> = {
}
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
const MOROCCAN_PHONE_PATTERN = '^(?:(?:\\+|00)212|0)\\s?[5-7](?:\\s?\\d){8}$'
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
const COMPANY_TABS = [
{ id: 'company', label: 'Company' },
{ id: 'subscription', label: 'Subscription' },
{ id: 'publicProfile', label: 'Public profile' },
{ id: 'legalProfile', label: 'Legal profile' },
{ id: 'operations', label: 'Operations & finance' },
{ id: 'activity', label: 'Activity' },
] as const
type CompanyTabId = (typeof COMPANY_TABS)[number]['id']
function toDateInput(value: string | null | undefined) {
return value ? new Date(value).toISOString().slice(0, 10) : ''
}
@@ -285,7 +275,7 @@ function createFormState(company: CompanyDetail): FormState {
publicCountry: company.brand?.publicCountry ?? '',
websiteUrl: company.brand?.websiteUrl ?? '',
whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'ar',
defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
},
@@ -296,6 +286,7 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false,
},
@@ -327,7 +318,6 @@ export default function AdminCompanyDetailPage() {
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
async function fetchData() {
try {
@@ -439,6 +429,7 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax,
},
@@ -575,57 +566,25 @@ export default function AdminCompanyDetailPage() {
))}
</div>
<div className="space-y-6">
<div className="overflow-x-auto border-b border-zinc-800">
<div className="flex min-w-max gap-1" role="tablist" aria-label="Company detail subjects">
{COMPANY_TABS.map((tab) => {
const selected = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`company-tab-${tab.id}`}
id={`company-tab-trigger-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`rounded-t-xl px-4 py-3 text-sm font-semibold transition-colors ${
selected
? 'bg-zinc-800 text-white'
: 'text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200'
}`}
>
{tab.label}
</button>
)
})}
</div>
</div>
<section
id="company-tab-company"
role="tabpanel"
aria-labelledby="company-tab-trigger-company"
hidden={activeTab !== 'company'}
className="panel p-6"
>
<div className="grid gap-6 xl:grid-cols-2">
<section className="panel p-6">
<h2 className="text-base font-semibold">Company</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Name</span>
<input className={INPUT_CLASS} value={form.company.name} maxLength={100} onChange={(e) => updateSection('company', { name: e.target.value })} />
<input className={INPUT_CLASS} value={form.company.name} onChange={(e) => updateSection('company', { name: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Slug</span>
<input className={INPUT_CLASS} value={form.company.slug} maxLength={50} pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$" onChange={(e) => updateSection('company', { slug: e.target.value.toLowerCase() })} />
<input className={INPUT_CLASS} value={form.company.slug} onChange={(e) => updateSection('company', { slug: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Email</span>
<input className={INPUT_CLASS} type="email" value={form.company.email} maxLength={254} onChange={(e) => updateSection('company', { email: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.company.email} onChange={(e) => updateSection('company', { email: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Phone</span>
<input className={INPUT_CLASS} type="tel" value={form.company.phone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('company', { phone: e.target.value })} />
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
</label>
<div>
<span className={LABEL_CLASS}>Company status</span>
@@ -633,18 +592,12 @@ export default function AdminCompanyDetailPage() {
</div>
<label>
<span className={LABEL_CLASS}>Subscription payment ref</span>
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} maxLength={120} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
</label>
</div>
</section>
<section
id="company-tab-subscription"
role="tabpanel"
aria-labelledby="company-tab-trigger-subscription"
hidden={activeTab !== 'subscription'}
className="panel p-6"
>
<section className="panel p-6">
<h2 className="text-base font-semibold">Subscription</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -710,34 +663,28 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section
id="company-tab-publicProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-publicProfile"
hidden={activeTab !== 'publicProfile'}
className="panel p-6"
>
<section className="panel p-6">
<h2 className="text-base font-semibold">Brand and public profile</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Display name</span>
<input className={INPUT_CLASS} value={form.brand.displayName} maxLength={100} onChange={(e) => updateSection('brand', { displayName: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.displayName} onChange={(e) => updateSection('brand', { displayName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Subdomain</span>
<input className={INPUT_CLASS} value={form.brand.subdomain} maxLength={50} pattern="^[a-z0-9-]+$" onChange={(e) => updateSection('brand', { subdomain: e.target.value.toLowerCase() })} />
<input className={INPUT_CLASS} value={form.brand.subdomain} onChange={(e) => updateSection('brand', { subdomain: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Tagline</span>
<input className={INPUT_CLASS} value={form.brand.tagline} maxLength={160} onChange={(e) => updateSection('brand', { tagline: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.tagline} onChange={(e) => updateSection('brand', { tagline: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Public email</span>
<input className={INPUT_CLASS} type="email" value={form.brand.publicEmail} maxLength={254} onChange={(e) => updateSection('brand', { publicEmail: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.brand.publicEmail} onChange={(e) => updateSection('brand', { publicEmail: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Public phone</span>
<input className={INPUT_CLASS} type="tel" value={form.brand.publicPhone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('brand', { publicPhone: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.publicPhone} onChange={(e) => updateSection('brand', { publicPhone: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Custom domain</span>
@@ -749,11 +696,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>WhatsApp</span>
<input className={INPUT_CLASS} type="tel" value={form.brand.whatsappNumber} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('brand', { whatsappNumber: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.whatsappNumber} onChange={(e) => updateSection('brand', { whatsappNumber: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Locale</span>
<input className={INPUT_CLASS} value={form.brand.defaultLocale} maxLength={2} pattern="^(en|fr|ar)$" onChange={(e) => updateSection('brand', { defaultLocale: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.defaultLocale} onChange={(e) => updateSection('brand', { defaultLocale: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Brand currency</span>
@@ -765,15 +712,15 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>City</span>
<input className={INPUT_CLASS} value={form.brand.publicCity} maxLength={85} onChange={(e) => updateSection('brand', { publicCity: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.publicCity} onChange={(e) => updateSection('brand', { publicCity: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Country</span>
<input className={INPUT_CLASS} value={form.brand.publicCountry || 'MA'} maxLength={2} pattern="^[A-Z]{2}$" onChange={(e) => updateSection('brand', { publicCountry: e.target.value.toUpperCase() })} />
<input className={INPUT_CLASS} value={form.brand.publicCountry} onChange={(e) => updateSection('brand', { publicCountry: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Address</span>
<input className={INPUT_CLASS} value={form.brand.publicAddress} maxLength={255} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
@@ -782,13 +729,7 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section
id="company-tab-legalProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-legalProfile"
hidden={activeTab !== 'legalProfile'}
className="panel p-6"
>
<section className="panel p-6">
<h2 className="text-base font-semibold">Company legal profile</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -798,11 +739,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Manager / owner name</span>
<input className={INPUT_CLASS} value={form.companyProfile.managerName} maxLength={100} onChange={(e) => updateSection('companyProfile', { managerName: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.managerName} onChange={(e) => updateSection('companyProfile', { managerName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>ZIP / postal code</span>
<input className={INPUT_CLASS} value={form.companyProfile.zipCode} maxLength={10} onChange={(e) => updateSection('companyProfile', { zipCode: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.zipCode} onChange={(e) => updateSection('companyProfile', { zipCode: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Fax</span>
@@ -814,11 +755,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>ICE number</span>
<input className={INPUT_CLASS} value={form.companyProfile.iceNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { iceNumber: e.target.value.toUpperCase() })} />
<input className={INPUT_CLASS} value={form.companyProfile.iceNumber} onChange={(e) => updateSection('companyProfile', { iceNumber: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Operating license number</span>
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { operatingLicenseNumber: e.target.value.toUpperCase() })} />
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseNumber} onChange={(e) => updateSection('companyProfile', { operatingLicenseNumber: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Operating license issue date</span>
@@ -857,7 +798,7 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Identity document number</span>
<input className={INPUT_CLASS} value={form.companyProfile.responsibleIdentityNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { responsibleIdentityNumber: e.target.value.toUpperCase() })} />
<input className={INPUT_CLASS} value={form.companyProfile.responsibleIdentityNumber} onChange={(e) => updateSection('companyProfile', { responsibleIdentityNumber: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Qualification / diploma / experience</span>
@@ -865,38 +806,32 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Responsible phone</span>
<input className={INPUT_CLASS} type="tel" value={form.companyProfile.responsiblePhone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('companyProfile', { responsiblePhone: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.responsiblePhone} onChange={(e) => updateSection('companyProfile', { responsiblePhone: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Responsible email</span>
<input className={INPUT_CLASS} type="email" value={form.companyProfile.responsibleEmail} maxLength={254} onChange={(e) => updateSection('companyProfile', { responsibleEmail: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.companyProfile.responsibleEmail} onChange={(e) => updateSection('companyProfile', { responsibleEmail: e.target.value })} />
</label>
</div>
</div>
</div>
</section>
<section
id="company-tab-operations"
role="tabpanel"
aria-labelledby="company-tab-trigger-operations"
hidden={activeTab !== 'operations'}
className="panel p-6"
>
<section className="panel p-6">
<h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Legal name</span>
<input className={INPUT_CLASS} value={form.contractSettings.legalName} maxLength={100} onChange={(e) => updateSection('contractSettings', { legalName: e.target.value })} />
<input className={INPUT_CLASS} value={form.contractSettings.legalName} onChange={(e) => updateSection('contractSettings', { legalName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Registration number</span>
<input className={INPUT_CLASS} value={form.contractSettings.registrationNumber} maxLength={30} onChange={(e) => updateSection('contractSettings', { registrationNumber: e.target.value.toUpperCase() })} />
<input className={INPUT_CLASS} value={form.contractSettings.registrationNumber} onChange={(e) => updateSection('contractSettings', { registrationNumber: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax ID</span>
<input className={INPUT_CLASS} value={form.contractSettings.taxId} maxLength={30} onChange={(e) => updateSection('contractSettings', { taxId: e.target.value })} />
<input className={INPUT_CLASS} value={form.contractSettings.taxId} onChange={(e) => updateSection('contractSettings', { taxId: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Fuel policy type</span>
@@ -912,6 +847,10 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Late fee per hour</span>
<input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax rate</span>
<input className={INPUT_CLASS} type="number" step="0.01" value={form.contractSettings.taxRate} onChange={(e) => updateSection('contractSettings', { taxRate: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required
@@ -922,7 +861,7 @@ export default function AdminCompanyDetailPage() {
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Terms</span>
<textarea className={`${INPUT_CLASS} min-h-28`} value={form.contractSettings.terms} maxLength={4000} onChange={(e) => updateSection('contractSettings', { terms: e.target.value })} />
<textarea className={`${INPUT_CLASS} min-h-28`} value={form.contractSettings.terms} onChange={(e) => updateSection('contractSettings', { terms: e.target.value })} />
</label>
</div>
@@ -960,11 +899,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Accountant name</span>
<input className={INPUT_CLASS} value={form.accountingSettings.accountantName} maxLength={50} onChange={(e) => updateSection('accountingSettings', { accountantName: e.target.value })} />
<input className={INPUT_CLASS} value={form.accountingSettings.accountantName} onChange={(e) => updateSection('accountingSettings', { accountantName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Accountant email</span>
<input className={INPUT_CLASS} type="email" value={form.accountingSettings.accountantEmail} maxLength={254} onChange={(e) => updateSection('accountingSettings', { accountantEmail: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.accountingSettings.accountantEmail} onChange={(e) => updateSection('accountingSettings', { accountantEmail: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.accountingSettings.autoSendReport} onChange={(e) => updateSection('accountingSettings', { autoSendReport: e.target.checked })} />
@@ -976,13 +915,7 @@ export default function AdminCompanyDetailPage() {
</section>
</div>
<div
id="company-tab-activity"
role="tabpanel"
aria-labelledby="company-tab-trigger-activity"
hidden={activeTab !== 'activity'}
className="grid gap-6 lg:grid-cols-2"
>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Company actions</h2>
+429 -13
View File
@@ -1,19 +1,435 @@
'use client'
export default function ContainersPage() {
import { useCallback, useEffect, useRef, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
interface CompanyContainer {
id: string
companyId: string
dockerId: string | null
containerName: string
status: ContainerStatus
port: number
image: string
errorMessage: string | null
createdAt: string
updatedAt: string
company: {
id: string
name: string
slug: string
status: string
}
}
function authHeaders() {
return { 'Content-Type': 'application/json' }
}
function StatusBadge({ status }: { status: ContainerStatus }) {
const map: Record<ContainerStatus, { label: string; className: string }> = {
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
STOPPED: { label: 'Stopped', className: 'bg-orange-900 text-orange-300' },
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
}
const { label, className } = map[status] ?? map.ERROR
return (
<div className="mx-auto max-w-3xl px-6 py-16">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-400">Out of scope</p>
<h1 className="mt-3 text-2xl font-semibold text-zinc-100">Per-tenant containers disabled</h1>
<p className="mt-4 text-sm leading-6 text-zinc-400">
Company Docker container orchestration is not part of the production platform. The previous
admin UI and Docker-socket control plane have been removed from GA because they created a
high-privilege host trust boundary and were incomplete (no durable model or API surface).
</p>
<p className="mt-3 text-sm leading-6 text-zinc-500">
If isolated runtimes become a commercial requirement, they must be rebuilt as a separate
least-privilege deployment controller never inside the business API.
</p>
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
{label}
</span>
)
}
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
const [logs, setLogs] = useState<string>('')
const [loading, setLoading] = useState(true)
const [tail, setTail] = useState(150)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchLogs = useCallback(async (lines: number) => {
setLoading(true)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
const json = await res.json()
setLogs(json.data?.logs ?? '')
} catch {
setLogs('Failed to fetch logs.')
} finally {
setLoading(false)
}
}, [companyId])
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/70 p-4" onClick={onClose}>
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<div>
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
<p className="text-xs text-zinc-400">{companyName}</p>
</div>
<div className="flex items-center gap-3">
<select
value={tail}
onChange={(e) => setTail(Number(e.target.value))}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
>
<option value={50}>Last 50 lines</option>
<option value={150}>Last 150 lines</option>
<option value={500}>Last 500 lines</option>
<option value={1000}>Last 1000 lines</option>
</select>
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
Refresh
</button>
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-xs text-zinc-500">Loading</p>
) : (
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
{logs || 'No logs available.'}
</pre>
)}
<div ref={bottomRef} />
</div>
</div>
</div>
)
}
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
export default function ContainersPage() {
const [containers, setContainers] = useState<CompanyContainer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
const [search, setSearch] = useState('')
const [provisioning, setProvisioning] = useState(false)
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
const fetchContainers = useCallback(async () => {
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? 'Failed to load containers.')
}
setContainers(json.data ?? [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load containers.')
/* silent — keep old data */
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchContainers()
const id = setInterval(fetchContainers, 8000)
return () => clearInterval(id)
}, [fetchContainers])
async function provisionAll() {
setProvisioning(true)
setProvisionResults(null)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
setProvisionResults(json.data?.results ?? [])
setError(null)
await fetchContainers()
} catch (err) {
setError(err instanceof Error ? err.message : 'Provisioning failed.')
} finally {
setProvisioning(false)
}
}
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
setBusy((b) => ({ ...b, [companyId]: true }))
try {
const method = action === 'remove' ? 'DELETE' : 'POST'
const url =
action === 'remove'
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? `Failed to ${action} container.`)
}
setError(null)
await fetchContainers()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
} finally {
setBusy((b) => ({ ...b, [companyId]: false }))
}
}
const filtered = containers.filter(
(c) =>
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
)
const stats = {
running: containers.filter((c) => c.status === 'RUNNING').length,
stopped: containers.filter((c) => c.status === 'STOPPED').length,
error: containers.filter((c) => c.status === 'ERROR').length,
total: containers.length,
}
return (
<div className="min-h-full p-8">
{logsFor && (
<LogsModal
companyId={logsFor.companyId}
companyName={logsFor.companyName}
onClose={() => setLogsFor(null)}
/>
)}
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
</div>
<button
onClick={provisionAll}
disabled={provisioning}
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{provisioning ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Provisioning
</>
) : (
<>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
Provision All Accounts
</>
)}
</button>
</div>
{error && (
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
{provisionResults !== null && (
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium text-zinc-200">
Provisioning complete {' '}
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
{provisionResults.some((r) => r.status === 'error') && (
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
)}
</p>
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{provisionResults.map((r) => (
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
</div>
))}
</div>
</div>
)}
{/* Stats */}
<div className="mb-6 grid grid-cols-4 gap-4">
{[
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
{ label: 'Stopped', value: stats.stopped, color: 'text-orange-400' },
{ label: 'Error', value: stats.error, color: 'text-red-400' },
].map((s) => (
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500">{s.label}</p>
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Search */}
<div className="mb-4">
<input
type="text"
placeholder="Search by company name or container…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-sm text-zinc-500">
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th className="px-5 py-3 font-medium">Company</th>
<th className="px-5 py-3 font-medium">Container</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium">Port</th>
<th className="px-5 py-3 font-medium">Image</th>
<th className="px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{filtered.map((c) => {
const isBusy = busy[c.companyId] ?? false
const isRunning = c.status === 'RUNNING'
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
return (
<tr key={c.id} className="hover:bg-zinc-800/40">
<td className="px-5 py-4">
<p className="font-medium text-zinc-100">{c.company.name}</p>
<p className="text-xs text-zinc-500">{c.company.slug}</p>
{c.errorMessage && (
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
</p>
)}
</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
{c.containerName}
{c.dockerId && (
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
)}
</td>
<td className="px-5 py-4">
<StatusBadge status={c.status} />
</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
<td className="px-5 py-4">
<div className="flex items-center gap-1.5">
{isStopped && (
<ActionButton
label="Start"
color="emerald"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'start')}
/>
)}
{isRunning && (
<ActionButton
label="Stop"
color="yellow"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'stop')}
/>
)}
{(isRunning || isStopped) && (
<ActionButton
label="Restart"
color="blue"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'restart')}
/>
)}
<ActionButton
label="Redeploy"
color="purple"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'deploy')}
/>
<ActionButton
label="Logs"
color="zinc"
disabled={isBusy || !c.dockerId}
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
/>
<ActionButton
label="Remove"
color="red"
disabled={isBusy || isTransitioning}
onClick={() => {
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
act(c.companyId, 'remove')
}
}}
/>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}
function ActionButton({
label,
color,
disabled,
onClick,
}: {
label: string
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
disabled: boolean
onClick: () => void
}) {
const colorMap: Record<string, string> = {
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
yellow: 'border-orange-800 text-orange-400 hover:bg-orange-900/40',
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
}
return (
<button
onClick={onClick}
disabled={disabled}
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
>
{label}
</button>
)
}
+9 -272
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useEffect, useState } from 'react'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
@@ -13,13 +13,12 @@ import { ADMIN_API_BASE } from '@/lib/api'
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
function buildUnifiedLoginUrl(nextPath: string) {
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
const storedTheme = window.localStorage.getItem('rentaldrivego-theme') ?? window.localStorage.getItem('admin-theme')
const theme = storedTheme === 'light' ? 'light' : 'dark'
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const params = new URLSearchParams({
next: `/admin${nextPath || '/dashboard'}`,
portal: 'admin',
next: nextPath || '/dashboard',
})
return `${websiteUrl}/en/${theme}/admin-sign-in?${params.toString()}`
return `${dashboardUrl}/sign-in?${params.toString()}`
}
const navLinks = [
@@ -40,23 +39,12 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname()
const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
const [securitySetupOpen, setSecuritySetupOpen] = useState(false)
const redirectingToLogin = useRef(false)
function redirectToLogin() {
if (redirectingToLogin.current) return
redirectingToLogin.current = true
window.location.replace(buildUnifiedLoginUrl(pathname))
}
useEffect(() => {
let cancelled = false
const controller = new AbortController()
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (response) => {
if (cancelled) return
@@ -64,29 +52,21 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const json = await response.json().catch(() => null)
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
redirectToLogin()
window.location.replace(buildUnifiedLoginUrl(pathname))
return
}
setAdmin(resolvedAdmin)
setReady(true)
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
.then((inbox) => {
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
})
.catch(() => {})
} else {
redirectToLogin()
window.location.replace(buildUnifiedLoginUrl(pathname))
}
})
.catch((err) => {
if (err?.name === 'AbortError') return
if (!cancelled) redirectToLogin()
.catch(() => {
if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
})
return () => {
cancelled = true
controller.abort()
}
}, [pathname])
@@ -126,25 +106,11 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
{link.key === 'notifications' && unreadNotifications > 0 ? (
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
) : null}
</Link>
)
})}
</nav>
<div className="px-3 py-4">
{admin && !admin.totpEnabled ? (
<button
onClick={() => setSecuritySetupOpen(true)}
className="mb-2 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-orange-700 transition-colors hover:bg-orange-50 hover:text-orange-800 dark:text-orange-300 dark:hover:bg-[#162038] dark:hover:text-orange-200"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l2 2 4-4M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7l7-4z" />
</svg>
Enable 2FA
</button>
) : null}
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
@@ -161,236 +127,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</div>
</aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
{admin && securitySetupOpen ? (
<Admin2FASetupDialog
admin={admin}
onEnrolled={(updatedAdmin) => {
setAdmin(updatedAdmin)
setSecuritySetupOpen(false)
}}
onClose={() => setSecuritySetupOpen(false)}
/>
) : null}
</div>
</AdminSessionProvider>
)
}
function Admin2FASetupDialog({
admin,
onEnrolled,
onClose,
}: {
admin: AdminSessionUser
onEnrolled: (admin: AdminSessionUser) => void
onClose: () => void
}) {
type SetupMethod = 'email' | 'authenticator'
const [method, setMethod] = useState<SetupMethod | null>(null)
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loadingSetup, setLoadingSetup] = useState(false)
const [verifying, setVerifying] = useState(false)
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
async function startSetup(nextMethod: SetupMethod) {
setMethod(nextMethod)
setCode('')
setError(null)
setLoadingSetup(true)
const endpoint = nextMethod === 'email'
? `${ADMIN_API_BASE}/admin/auth/2fa/email/setup`
: `${ADMIN_API_BASE}/admin/auth/2fa/setup`
try {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
const data = json?.data ?? json
setSecret(data?.secret ?? '')
setQrCode(data?.qrCode ?? '')
} catch (err: any) {
setError(err?.message ?? 'Failed to start 2FA setup.')
} finally {
setLoadingSetup(false)
}
}
async function verifyCode(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedCode = code.trim()
if (!/^\d{6}$/.test(normalizedCode)) {
setError('Enter the 6-digit code from your authenticator app.')
return
}
setError(null)
setVerifying(true)
const endpoint = method === 'email'
? `${ADMIN_API_BASE}/admin/auth/2fa/email/verify`
: `${ADMIN_API_BASE}/admin/auth/2fa/verify`
try {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: normalizedCode }),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
const data = json?.data ?? json
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
} catch (err: any) {
setError(err?.message ?? 'Invalid 2FA code.')
} finally {
setVerifying(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-6 text-stone-900 backdrop-blur-sm dark:text-slate-100">
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">Security</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Enable 2FA</h1>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
Close
</button>
</div>
{verifiedAdmin ? (
<div className="mt-8 space-y-5">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
2FA is enabled. Save your recovery codes before continuing.
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((recoveryCode) => (
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
{recoveryCode}
</code>
))}
</div>
</div>
) : null}
<button
type="button"
onClick={() => onEnrolled(verifiedAdmin)}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
Continue to admin dashboard
</button>
</div>
) : (
<form onSubmit={verifyCode} className="mt-8 space-y-6">
{!method ? (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => startSetup('email')}
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
>
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Email code</span>
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">{admin.email}</span>
</button>
<button
type="button"
onClick={() => startSetup('authenticator')}
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
>
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</span>
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">TOTP</span>
</button>
</div>
) : loadingSetup ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
Preparing setup...
</div>
) : method === 'email' ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
Enter the 6-digit code sent to {admin.email}.
</div>
) : (
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
</div>
<div>
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Scan the QR code with your authenticator app, or enter the setup key manually.
</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
placeholder="000000"
disabled={!method || loadingSetup || verifying}
/>
</label>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<button
type="submit"
disabled={!method || loadingSetup || verifying || code.length !== 6}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? 'Verifying...' : 'Enable 2FA'}
</button>
{method ? (
<button
type="button"
onClick={() => {
setMethod(null)
setCode('')
setError(null)
setSecret('')
setQrCode('')
}}
className="w-full rounded-full border border-stone-200 px-6 py-3 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
Choose another method
</button>
) : null}
</form>
)}
</section>
</div>
)
}
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type CompanyOption = {
@@ -82,7 +82,7 @@ type FormState = {
}
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
const INPUT =
@@ -117,7 +117,7 @@ function emptyForm(): FormState {
isRequired: false,
isActive: true,
roles: ['OWNER', 'MANAGER', 'AGENT'],
plans: ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'],
plans: ['STARTER', 'GROWTH', 'PRO'],
companyIds: [],
}
}
@@ -1,22 +1,32 @@
'use client'
import { useEffect, useState } from 'react'
import {
fetchAdminNotifications,
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
import { ADMIN_API_BASE } from '@/lib/api'
interface AdminInboxItem {
interface NotificationItem {
id: string
readAt: string | null
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
company: { name: string } | null
companyId: string | null
renterId: string | null
}
interface Paginated {
data: NotificationItem[]
total: number
page: number
pageSize: number
}
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'text-sky-400 bg-sky-950/40',
@@ -31,66 +41,39 @@ const STATUS_BADGE: Record<string, string> = {
SENT: 'text-emerald-400 bg-emerald-950/40',
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
FAILED: 'text-red-400 bg-red-950/40',
QUEUED: 'text-sky-400 bg-sky-950/40',
SKIPPED: 'text-zinc-400 bg-zinc-800',
DEAD_LETTER: 'text-red-300 bg-red-950/60',
READ: 'text-zinc-400 bg-zinc-800',
}
function formatRecipient(item: {
recipientType: string | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}) {
const fallbackId = item.employeeId ?? item.renterId
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
return item.recipientType ? `${item.recipientType}: ${label}` : label
}
export default function AdminNotificationsPage() {
const [result, setResult] = useState<NotificationsPageResult | null>(null)
const [result, setResult] = useState<Paginated | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
function loadInbox() {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
.then((json) => setInbox(json.data))
.catch(() => {})
}
function load(p: number, signal?: AbortSignal) {
function load(p: number) {
setLoading(true)
setError(null)
fetchAdminNotifications(
p,
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
signal,
)
.then((data) => setResult(data))
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return
setResult(null)
setError(err instanceof Error ? err.message : 'Failed to load notifications')
})
.finally(() => {
if (!signal?.aborted) setLoading(false)
})
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
if (filterCompany) params.set('companyId', filterCompany)
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setResult(json.data ?? null))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
const controller = new AbortController()
setPage(1)
load(1, controller.signal)
loadInbox()
return () => controller.abort()
load(1)
}, [filterChannel, filterStatus, filterCompany])
function goToPage(p: number) {
@@ -98,12 +81,7 @@ export default function AdminNotificationsPage() {
load(p)
}
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
async function markRead(recipientId: string) {
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (response.ok) loadInbox()
}
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
return (
<div className="shell py-8 space-y-6">
@@ -122,27 +100,6 @@ export default function AdminNotificationsPage() {
)}
</div>
<section className="panel p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
</div>
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
</div>
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{inbox.data.map((item) => (
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
</button>
))}
</div>
)}
</section>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
@@ -161,12 +118,6 @@ export default function AdminNotificationsPage() {
<option value="">All statuses</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
value={filterCompany}
onChange={(e) => setFilterCompany(e.target.value)}
placeholder="Company ID"
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
@@ -178,7 +129,6 @@ export default function AdminNotificationsPage() {
<tr className="border-b border-zinc-800">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
@@ -188,23 +138,17 @@ export default function AdminNotificationsPage() {
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
) : !result || result.data.length === 0 ? (
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
) : result.data.map((item) => (
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{formatNotificationDate(item.createdAt)}
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
</td>
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
<p className="truncate">{formatRecipient(item)}</p>
{item.recipientEmail && item.recipientName ? (
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
) : null}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
{item.type.replaceAll('_', ' ')}
</td>
@@ -223,7 +167,7 @@ export default function AdminNotificationsPage() {
</span>
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{formatNotificationDate(item.sentAt)}
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
</td>
</tr>
))}
@@ -64,21 +64,19 @@ type PlanFeatureForm = {
// ─── Constants ─────────────────────────────────────────────────────────────
const PLANS = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const PLANS = ['STARTER', 'GROWTH', 'PRO']
const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-400',
GROWTH: 'text-sky-400',
PRO: 'text-violet-400',
ENTERPRISE: 'text-emerald-400',
}
const PLAN_BADGE: Record<string, string> = {
STARTER: 'bg-zinc-800 text-zinc-300',
GROWTH: 'bg-sky-900/40 text-sky-300',
PRO: 'bg-violet-900/40 text-violet-300',
ENTERPRISE: 'bg-emerald-900/40 text-emerald-300',
}
// ─── Helpers ───────────────────────────────────────────────────────────────
@@ -62,7 +62,6 @@ export default function AdminForgotPasswordPage() {
<input
type="email"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@rentaldrivego.com"
-30
View File
@@ -154,10 +154,6 @@ html.dark .panel {
color: rgb(28 25 23);
}
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 {
color: rgb(120 113 108);
}
@@ -175,30 +171,4 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover {
color: rgb(28 25 23);
}
.light .text-amber-100,
.light .text-amber-100\/80,
.light .text-amber-200,
.light .text-amber-300 {
color: rgb(146 64 14);
}
.light .text-emerald-300 {
color: rgb(4 120 87);
}
.light .text-rose-200,
.light .text-rose-300,
.light .text-red-400 {
color: rgb(190 18 60);
}
.light .text-sky-300 {
color: rgb(3 105 161);
}
.light .text-red-200,
.light .text-red-300 {
color: rgb(185 28 28);
}
}
+3 -6
View File
@@ -1,5 +1,4 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css'
@@ -10,14 +9,12 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ar" dir="rtl" className="dark" suppressHydrationWarning>
<html lang="en" className="light" suppressHydrationWarning>
<head>
<Script
id="admin-theme-bootstrap"
strategy="beforeInteractive"
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
+4 -7
View File
@@ -1,16 +1,13 @@
import { cookies, headers } from 'next/headers'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function AdminLoginPage() {
const requestHeaders = await headers()
const cookieStore = await cookies()
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
const theme = rawTheme === 'light' ? 'light' : 'dark'
const websiteUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${websiteUrl}/en/${theme}/admin-sign-in?next=/admin/dashboard`)
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
}
@@ -102,7 +102,6 @@ function AdminResetPasswordContent() {
type={showPassword ? 'text' : 'password'}
required
minLength={8}
maxLength={128}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
@@ -134,7 +133,6 @@ function AdminResetPasswordContent() {
type={showConfirm ? 'text' : 'password'}
required
minLength={8}
maxLength={128}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
+9 -9
View File
@@ -109,12 +109,11 @@ type AdminI18nContext = {
const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('ar')
const [theme, setTheme] = useState<AdminTheme>('dark')
// Skip the very first write so we don't overwrite a stored preference before
// the hydration read-effect has applied it.
const [language, setLanguage] = useState<AdminLanguage>('en')
const [theme, setTheme] = useState<AdminTheme>('light')
// Skip the very first write so we don't overwrite a stored preference with
// the default 'en' value before the hydration read-effect has applied it.
const skipFirstLangWrite = useRef(true)
const skipFirstThemeWrite = useRef(true)
useEffect(() => {
const stored = window.localStorage.getItem('admin-language')
@@ -125,6 +124,11 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
return
}
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('light')
}
}, [])
@@ -143,10 +147,6 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme
if (skipFirstThemeWrite.current) {
skipFirstThemeWrite.current = false
return
}
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
window.localStorage.setItem(SHARED_THEME_KEY, theme)
window.localStorage.setItem('admin-theme', theme)
@@ -1,82 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(() => {
delete process.env.API_INTERNAL_URL
delete process.env.NEXT_PUBLIC_API_URL
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('buildNotificationsQuery', () => {
it('builds page, page size, and non-empty filters', async () => {
const { buildNotificationsQuery } = await import('./adminNotifications')
expect(buildNotificationsQuery(3, {
channel: 'EMAIL',
status: 'FAILED',
companyId: ' company_1 ',
}).toString()).toBe('page=3&pageSize=50&channel=EMAIL&status=FAILED&companyId=company_1')
})
it('omits empty filters', async () => {
const { buildNotificationsQuery } = await import('./adminNotifications')
expect(buildNotificationsQuery(1, {
channel: '',
status: '',
companyId: ' ',
}).toString()).toBe('page=1&pageSize=50')
})
})
describe('fetchAdminNotifications', () => {
it('returns the paginated data envelope', async () => {
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
const payload = { data: [], total: 0, page: 1, pageSize: 50, totalPages: 0 }
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: payload }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1, { status: 'READ' })).resolves.toEqual(payload)
expect(fetchMock).toHaveBeenCalledWith(
'http://internal-api/api/v1/admin/notifications?page=1&pageSize=50&status=READ',
expect.objectContaining({ credentials: 'include', cache: 'no-store' }),
)
})
it('throws API error messages', async () => {
const fetchMock = vi.fn(async () => ({
ok: false,
json: async () => ({ message: 'Support role required' }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1)).rejects.toThrow('Support role required')
})
it('throws when the API envelope is malformed', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { notifications: [] } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1)).rejects.toThrow('Notifications response was not in the expected format')
})
})
describe('formatNotificationDate', () => {
it('formats missing or invalid dates as a placeholder', async () => {
const { formatNotificationDate } = await import('./adminNotifications')
expect(formatNotificationDate(null)).toBe('-')
expect(formatNotificationDate('not-a-date')).toBe('-')
})
})
-89
View File
@@ -1,89 +0,0 @@
import { ADMIN_API_BASE } from './api'
export interface NotificationItem {
id: string
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string
company: { name: string } | null
companyId: string | null
recipientType: 'EMPLOYEE' | 'RENTER' | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}
export interface NotificationsPageResult {
data: NotificationItem[]
total: number
page: number
pageSize: number
totalPages?: number
}
export interface NotificationFilters {
channel?: string
status?: string
companyId?: string
}
function isNotificationsPageResult(value: unknown): value is NotificationsPageResult {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<NotificationsPageResult>
return (
Array.isArray(candidate.data) &&
typeof candidate.total === 'number' &&
typeof candidate.page === 'number' &&
typeof candidate.pageSize === 'number'
)
}
export function buildNotificationsQuery(page: number, filters: NotificationFilters = {}) {
const params = new URLSearchParams({
page: String(page),
pageSize: '50',
})
if (filters.channel) params.set('channel', filters.channel)
if (filters.status) params.set('status', filters.status)
if (filters.companyId?.trim()) params.set('companyId', filters.companyId.trim())
return params
}
export async function fetchAdminNotifications(
page: number,
filters: NotificationFilters = {},
signal?: AbortSignal,
): Promise<NotificationsPageResult> {
const params = buildNotificationsQuery(page, filters)
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
signal,
})
const json = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(json?.message ?? 'Failed to load notifications')
}
if (!isNotificationsPageResult(json?.data)) {
throw new Error('Notifications response was not in the expected format')
}
return json.data
}
export function formatNotificationDate(value: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}
-6
View File
@@ -31,12 +31,6 @@ describe('admin app URL resolution', () => {
expect(resolveServerAppUrl('http://localhost:3002/admin', 'admin.example.com', 'https')).toBe('https://admin.example.com:3002/admin')
})
it('does not expose internal development hosts in server redirects', () => {
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'host.docker.internal:3002', 'http')).toBe('http://localhost:3000/dashboard')
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'admin:3002', 'http')).toBe('http://localhost:3000/dashboard')
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'localhost:3002', 'http')).toBe('http://localhost:3000/dashboard')
})
it('falls back when host is missing or the fallback is not parseable', () => {
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
+1 -6
View File
@@ -14,13 +14,8 @@ export function resolveBrowserAppUrl(fallback: string): string {
}
}
function isInternalHost(host: string): boolean {
const hostname = host.split(':')[0]?.toLowerCase()
return ['localhost', '127.0.0.1', 'host.docker.internal', 'dashboard', 'admin', 'api', 'homepage', 'carplace'].includes(hostname)
}
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
if (!host || isInternalHost(host)) return fallback
if (!host) return fallback
try {
const target = new URL(fallback)
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
export function middleware(request: NextRequest) {
if (request.headers.has('x-middleware-subrequest')) {
return new NextResponse('Unsupported internal request header', { status: 400 })
}
-24
View File
@@ -1,24 +0,0 @@
# Manual subscription payments are intentionally off until every dependency is ready.
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=false
BANK_TRANSFER_ENABLED=false
BANK_TRANSFER_ACCOUNT_NAME=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_ACCOUNT_REFERENCE=
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=false
CHECK_PAYMENT_PAYEE=
CHECK_PAYMENT_DELIVERY_ADDRESS=
CHECK_PAYMENT_DUE_DAYS=14
# Evidence is private, quarantined, content-validated and fail-closed scanned.
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=false
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/uploads
PRIVATE_STORAGE_PERSISTENCE_CONFIRMED=false
PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED=false
PAYMENT_EVIDENCE_SCANNER_PATH=/usr/bin/clamscan
PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS=60000
# Collections notifications can be proven before automatic suspension is enabled.
SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED=false
SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED=false
DEFAULT_BILLING_TIMEZONE=Africa/Casablanca
-4
View File
@@ -10,9 +10,6 @@
"prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "node dist/index.js",
"worker": "node dist/workers/index.js",
"preworker:dev": "npm run build --workspace @rentaldrivego/types",
"worker:dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/workers/index.ts",
"type-check": "tsc --noEmit",
"pretest": "npm run build --workspace @rentaldrivego/types",
"test": "vitest run",
@@ -37,7 +34,6 @@
"firebase-admin": "^10.3.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"@aws-sdk/client-s3": "^3.758.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.1.1",
+21 -157
View File
@@ -1,22 +1,18 @@
import express, { type Request, type Response } from 'express'
import express from 'express'
import cors, { type CorsOptions } from 'cors'
import helmet from 'helmet'
import morgan from 'morgan'
import swaggerUi from 'swagger-ui-express'
import { openApiDocument } from './swagger/openapi'
import { getPublicStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter } from './middleware/rateLimiter'
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
import { requestIdMiddleware } from './middleware/requestId'
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
// ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes'
import companyAuthRouter from './modules/auth/auth.company.routes'
import employeeAuthRouter from './modules/auth/auth.employee.routes'
import accountAuthRouter from './modules/auth/auth.account.routes'
import unifiedAuthRouter from './modules/auth/auth.unified.routes'
import renterAuthRouter from './modules/auth/auth.renter.routes'
import teamRouter from './modules/team/team.routes'
import offersRouter from './modules/offers/offer.routes'
@@ -25,6 +21,7 @@ import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter, {
subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
import billingRouter from './modules/billing/billing.routes'
@@ -37,7 +34,6 @@ import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
import licenseValidationRouter from './modules/licenses/license.validation.routes'
import searchRouter from './modules/search/search.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -55,65 +51,20 @@ const defaultCorsOrigins = [
'http://127.0.0.1:4000',
]
const frontendOriginEnvKeys = [
'SITE_ORIGIN',
'WEBSITE_URL',
'HOMEPAGE_URL',
'DASHBOARD_URL',
'ADMIN_URL',
'CARPLACE_URL',
'NEXT_PUBLIC_HOMEPAGE_URL',
'NEXT_PUBLIC_WEBSITE_URL',
'NEXT_PUBLIC_DASHBOARD_URL',
'NEXT_PUBLIC_ADMIN_URL',
'NEXT_PUBLIC_CARPLACE_URL',
] as const
function normalizeConfiguredOrigin(value: string): string | null {
try {
return new URL(value).origin
} catch {
return null
}
}
export function getConfiguredCorsOrigins(env: NodeJS.ProcessEnv = process.env) {
const configuredOrigins = (env.CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
const frontendOrigins = frontendOriginEnvKeys
.flatMap((key) => (env[key] ?? '').split(','))
.map((origin) => origin.trim())
.filter(Boolean)
const rawOrigins = configuredOrigins.length > 0 || frontendOrigins.length > 0
? [...configuredOrigins, ...frontendOrigins]
: defaultCorsOrigins
return Array.from(new Set(rawOrigins.map(normalizeConfiguredOrigin).filter((origin): origin is string => Boolean(origin))))
}
export const corsOrigins = getConfiguredCorsOrigins()
export const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
: defaultCorsOrigins
function isAllowedLocalDevOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
if (url.protocol !== 'http:') return false
if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
const octets = url.hostname.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false
}
const first = octets[0]!
const second = octets[1]!
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
return (
url.protocol === 'http:' &&
['localhost', '127.0.0.1'].includes(url.hostname) &&
['3000', '3001', '3002', '4000'].includes(url.port)
)
} catch {
return false
}
@@ -132,9 +83,7 @@ export const corsOptions: CorsOptions = {
}
const routeDocs = [
{ method: 'GET', path: '/health', description: 'Liveness health check' },
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
@@ -145,7 +94,6 @@ const routeDocs = [
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/search`, description: 'Global account search' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
@@ -167,9 +115,7 @@ export function createApp() {
app.set('trust proxy', 1)
}
app.use(sanitizeForwardedHeaders)
app.use(requestIdMiddleware)
app.use(metricsMiddleware)
app.use((req, res, next) => {
if (req.headers['x-middleware-subrequest']) {
@@ -179,16 +125,12 @@ export function createApp() {
})
app.use(corsMiddleware)
app.use(requireTrustedOriginForCookieMutations)
// Customer identity documents must never be anonymously retrievable from the
// public storage mount, even if an older raw storage URL leaks.
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
res.status(404).end()
})
app.use('/storage/companies/:companyId/reservations/:reservationId', (_req, res) => {
res.status(404).end()
})
// Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin
// by the browser. Without this header, helmet's default CORP: same-origin reaches
@@ -203,27 +145,15 @@ export function createApp() {
// through authenticated API routes such as /customers/:id/license-image.
app.use('/storage', express.static(getPublicStorageRoot()))
const publicDocsEnabled = process.env.NODE_ENV !== 'production' || process.env.ENABLE_PUBLIC_API_DOCS === 'true'
const docsDisabled = (_req: Request, res: Response) => res.status(404).json({ error: 'not_found', message: 'API docs are not available in this environment', statusCode: 404 })
// Swagger UI — never expose API reconnaissance material publicly in production
// unless explicitly enabled for a protected/internal deployment.
if (publicDocsEnabled) {
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
} else {
app.use('/docs', docsDisabled)
app.get('/api/v1/openapi.json', docsDisabled)
}
// Swagger UI — mounted before helmet so its assets are not blocked by CSP
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
// Webhooks must use raw body BEFORE express.json(); signature verification
// must never reconstruct the payload with JSON.stringify(req.body).
const removedOnlinePaymentWebhooks = (_req: Request, res: Response) => {
res.status(404).json({ error: 'not_found', message: 'Online payment webhooks have been removed', statusCode: 404 })
}
app.use(`${v1}/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), webhookRouter)
app.use(`${v1}/payments/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), removedOnlinePaymentWebhooks)
app.use(`${v1}/subscriptions/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), removedOnlinePaymentWebhooks)
app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter)
app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' }))
app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' }))
// Let /storage responses manage CORP explicitly so missing files still return
// a normal cross-origin 404 instead of being blocked by Helmet's default
@@ -248,39 +178,22 @@ export function createApp() {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
frameguard: { action: 'deny' },
}))
if (process.env.NODE_ENV !== 'test') {
app.use(morgan((tokens, req, res) => {
const requestId = (req as any).requestId ?? '-'
return JSON.stringify({
level: 'info',
msg: 'http_request',
requestId,
method: tokens.method(req, res),
url: tokens.url(req, res),
status: Number(tokens.status(req, res)),
durationMs: Number(tokens['response-time'](req, res)),
contentLength: tokens.res(req, res, 'content-length'),
})
}))
}
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ─────────────────────────────────────────────
app.use(`${v1}/auth`, authLimiter, unifiedAuthRouter)
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
app.use(`${v1}/admin/auth`, (req, res, next) => {
if (req.method === 'GET' && req.path === '/me') return next()
return authLimiter(req, res, next)
})
app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
@@ -295,7 +208,6 @@ export function createApp() {
app.use(`${v1}/billing`, apiLimiter, billingRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/search`, apiLimiter, searchRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
// ─── Health / Docs ──────────────────────────────────────────
@@ -314,55 +226,7 @@ export function createApp() {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
})
app.get('/metrics', async (_req, res) => {
try {
const { prisma } = await import('./lib/prisma')
const [pending, published] = await Promise.all([
prisma.notificationOutbox.count({ where: { status: 'PENDING' } }),
prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }),
])
setGauge('notification_outbox_pending', pending)
setGauge('notification_outbox_completed', published)
} catch {
/* leave previous gauges */
}
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
res.status(200).send(renderPrometheusText())
})
app.get('/ready', async (_req, res) => {
const { prisma } = await import('./lib/prisma')
const { redis } = await import('./lib/redis')
const { checkStorageReady } = await import('./lib/storage')
const checks: Record<string, 'ok' | 'error'> = { database: 'error', redis: 'error', storage: 'error' }
try {
await prisma.$queryRaw`SELECT 1`
checks.database = 'ok'
} catch {
checks.database = 'error'
}
try {
const pong = await redis.ping()
checks.redis = pong === 'PONG' ? 'ok' : 'error'
} catch {
checks.redis = 'error'
}
try {
await checkStorageReady()
checks.storage = 'ok'
} catch {
checks.storage = 'error'
}
const ready = Object.values(checks).every((v) => v === 'ok')
res.status(ready ? 200 : 503).json({
status: ready ? 'ready' : 'not_ready',
checks,
timestamp: new Date().toISOString(),
})
})
app.get(`${v1}/docs`, (_req, res) => {
if (!publicDocsEnabled) return docsDisabled(_req, res)
res.json({
name: 'rentaldrivego-api',
version: '1.0.0',
+2 -48
View File
@@ -2,7 +2,7 @@ import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
@@ -16,7 +16,7 @@ const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
*/
export const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE, files: 5, fields: 20, parts: 30 },
limits: { fileSize: MAX_FILE_SIZE, files: 20 },
})
type DetectedFile = { mime: string; ext: string }
@@ -46,50 +46,6 @@ export function detectImageType(buffer: Buffer): DetectedFile | null {
return null
}
function readImageDimensions(file: Express.Multer.File): { width: number; height: number } | null {
const buffer = file.buffer
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
}
if (buffer.length >= 10 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
const chunk = buffer.subarray(12, 16).toString('ascii')
if (chunk === 'VP8X' && buffer.length >= 30) {
const width = 1 + buffer.readUIntLE(24, 3)
const height = 1 + buffer.readUIntLE(27, 3)
return { width, height }
}
}
if (buffer.length >= 10 && buffer[0] === 0xff && buffer[1] === 0xd8) {
let offset = 2
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) return null
const marker = buffer.readUInt8(offset + 1)
const length = buffer.readUInt16BE(offset + 2)
if (length < 2) return null
if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
}
offset += 2 + length
}
}
return null
}
function assertSafeImageDimensions(file: Express.Multer.File) {
const dimensions = readImageDimensions(file)
if (!dimensions) return
const maxPixels = 24_000_000
const maxSide = 8_000
if (dimensions.width <= 0 || dimensions.height <= 0 || dimensions.width > maxSide || dimensions.height > maxSide || dimensions.width * dimensions.height > maxPixels) {
throw new ValidationError(`Image dimensions are too large for "${file.originalname}"`)
}
}
function assertSafeImageContent(file: Express.Multer.File) {
const detected = detectImageType(file.buffer)
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
@@ -105,8 +61,6 @@ function assertSafeImageContent(file: Express.Multer.File) {
if (ext && !allowedExtensions.includes(ext)) {
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
}
assertSafeImageDimensions(file)
}
/**
@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest'
import { assertPaymentEvidenceFile, sanitizeEvidenceFilename } from './paymentEvidence'
function file(buffer: Buffer, originalname: string, mimetype: string): Express.Multer.File {
return { buffer, originalname, mimetype, size: buffer.length } as Express.Multer.File
}
function png(width = 16, height = 16) {
const head = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from([0, 0, 0, 13]),
Buffer.from('IHDR'),
])
const dimensions = Buffer.alloc(8)
dimensions.writeUInt32BE(width, 0)
dimensions.writeUInt32BE(height, 4)
const ihdrRest = Buffer.alloc(9)
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
return Buffer.concat([head, dimensions, ihdrRest, iend])
}
function jpeg(width = 16, height = 16) {
return Buffer.from([
0xff, 0xd8,
0xff, 0xc0, 0x00, 0x0b, 0x08,
(height >> 8) & 0xff, height & 0xff,
(width >> 8) & 0xff, width & 0xff,
0x01, 0x01, 0x11, 0x00,
0xff, 0xd9,
])
}
describe('payment evidence validation', () => {
it('accepts a structurally bounded PDF by content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs with trailing bytes after the EOF marker', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n\u0000\u0000')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs that contain common byte sequences inside document content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n(<html><svg>PK\u0003\u0004)</script>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid evidence files reported with compatible browser MIME aliases', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/octet-stream'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/x-pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid image evidence with trailing bytes and common JPEG extensions', () => {
expect(assertPaymentEvidenceFile(file(Buffer.concat([png(), Buffer.from('\n')]), 'receipt.png', 'image/x-png'))).toEqual({ mime: 'image/png', ext: '.png' })
expect(assertPaymentEvidenceFile(file(Buffer.concat([jpeg(), Buffer.from('\n')]), 'receipt.jfif', 'image/pjpeg'))).toEqual({ mime: 'image/jpeg', ext: '.jpg' })
})
it('rejects spoofed MIME types and active content', () => {
const html = Buffer.from('<!doctype html><script>alert(1)</script>')
expect(() => assertPaymentEvidenceFile(file(html, 'receipt.pdf', 'application/pdf'))).toThrow(/suspicious/i)
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)])
expect(() => assertPaymentEvidenceFile(file(png, 'receipt.pdf', 'application/pdf'))).toThrow(/valid PDF, JPEG, and PNG/i)
})
it('sanitizes filenames without allowing path traversal', () => {
expect(sanitizeEvidenceFilename('../../bank<receipt>.pdf')).toBe('bank_receipt_.pdf')
})
})
-128
View File
@@ -1,128 +0,0 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
export const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
export const PAYMENT_EVIDENCE_MAX_FILES = 3
export const PAYMENT_EVIDENCE_MAX_TOTAL_SIZE = 20 * 1024 * 1024
export const paymentEvidenceUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: PAYMENT_EVIDENCE_MAX_FILE_SIZE, files: 1, fields: 5, parts: 8 },
})
export type DetectedPaymentEvidence = {
mime: 'application/pdf' | 'image/jpeg' | 'image/png'
ext: '.pdf' | '.jpg' | '.png'
}
const allowedExtensions: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg', '.jpe', '.jfif'],
'image/png': ['.png'],
}
const allowedDeclaredMimes: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['application/pdf', 'application/x-pdf', 'application/octet-stream'],
'image/jpeg': ['image/jpeg', 'image/pjpeg', 'application/octet-stream'],
'image/png': ['image/png', 'image/x-png', 'application/octet-stream'],
}
function hasSpoofedLeadingContainerSignature(buffer: Buffer) {
const prefix = buffer.subarray(0, 512)
const text = prefix.toString('latin1').trimStart().toLowerCase()
return text.startsWith('<script')
|| text.startsWith('<!doctype html')
|| text.startsWith('<html')
|| text.startsWith('<svg')
|| prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x4d, 0x5a, 0x90, 0x00]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
}
function detectType(buffer: Buffer): DetectedPaymentEvidence | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
const iendIndex = buffer.lastIndexOf(iend)
if (buffer.length >= 33 && iendIndex >= 0 && buffer.length - iendIndex <= 2048) {
return { mime: 'image/png', ext: '.png' }
}
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
const eoiIndex = buffer.lastIndexOf(Buffer.from([0xff, 0xd9]))
if (eoiIndex >= 0 && buffer.length - eoiIndex <= 2048) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
}
if (buffer.length >= 12 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
const content = buffer.toString('latin1')
// Real-world PDFs may include a newline or small binary marker after EOF.
// Require an EOF marker near the end instead of at the exact final byte.
const eofIndex = content.lastIndexOf('%%EOF')
if (eofIndex >= 0 && content.length - eofIndex <= 2048 && !/\/Encrypt\b/.test(content)) return { mime: 'application/pdf', ext: '.pdf' }
}
return null
}
function assertSafeJpegDimensions(buffer: Buffer) {
let offset = 2
while (offset + 8 < buffer.length) {
if (buffer[offset] !== 0xff) { offset += 1; continue }
const marker = buffer[offset + 1]
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2
continue
}
const segmentLength = buffer.readUInt16BE(offset + 2)
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) break
const isStartOfFrame = marker !== undefined
&& ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf))
if (isStartOfFrame) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
return
}
offset += 2 + segmentLength
}
throw new ValidationError('The JPEG file is malformed')
}
function assertSafePngDimensions(buffer: Buffer) {
if (buffer.length < 24) throw new ValidationError('The PNG file is malformed')
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
}
export function assertPaymentEvidenceFile(file: Express.Multer.File | undefined): DetectedPaymentEvidence {
if (!file) throw new ValidationError('A payment evidence file is required')
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
throw new ValidationError('Payment evidence files must be between 1 byte and 10 MB')
}
if (hasSpoofedLeadingContainerSignature(file.buffer)) throw new ValidationError('Unsupported or suspicious payment evidence file')
const detected = detectType(file.buffer)
if (!detected) throw new ValidationError('Only valid PDF, JPEG, and PNG evidence files are accepted')
const declaredMime = file.mimetype.toLowerCase()
if (!allowedDeclaredMimes[detected.mime].includes(declaredMime)) {
throw new ValidationError('The declared file type does not match its content')
}
const extension = path.extname(file.originalname).toLowerCase()
if (!allowedExtensions[detected.mime].includes(extension)) {
throw new ValidationError('The filename extension does not match the file content')
}
if (detected.mime === 'image/png') assertSafePngDimensions(file.buffer)
if (detected.mime === 'image/jpeg') assertSafeJpegDimensions(file.buffer)
return detected
}
export function sanitizeEvidenceFilename(value: string) {
const base = path.basename(value).normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, '').replace(/[^\p{L}\p{N}._ -]/gu, '_')
return (base || 'payment-evidence').slice(0, 180)
}
+192 -51
View File
@@ -1,22 +1,32 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Socket } from 'socket.io'
import cron from 'node-cron'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { startOutboxWorker, startScheduledJobs } from './workers/jobs'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
const app = createApp()
const app = createApp()
const server = http.createServer(app)
assertStorageConfiguration()
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
@@ -41,9 +51,10 @@ function getSocketSessionToken(socket: Socket): string | undefined {
)
}
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = getSocketSessionToken(socket)
if (!token) return next()
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub
@@ -55,9 +66,12 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const userId = (socket as any).authenticatedUserId as string | undefined
if (userId) socket.join(`user:${userId}`)
if (userId) {
socket.join(`user:${userId}`)
}
})
// Redis pub/sub → broadcast to connected clients
const subscriber = redis.duplicate()
subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', err)
@@ -72,14 +86,181 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
}
})
// Embedded jobs only when explicitly enabled (single-process local/dev).
// Production should run `npm run worker` / Compose `api-worker` instead.
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') {
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process')
startOutboxWorker()
startScheduledJobs()
}
// ─── Scheduled jobs ───────────────────────────────────────────
// Daily: flag expiring/expired licenses
cron.schedule('0 8 * * *', async () => {
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
for (const c of customers) {
if (!c.licenseExpiry) continue
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const expired = c.licenseExpiry <= new Date()
const expiring = !expired && daysLeft < 90
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
}
}
})
// Hourly: expire trials that ended without payment
cron.schedule('0 * * * *', async () => {
const n = await runTrialExpirationJob()
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Hourly: payment_pending → past_due after 7 days
cron.schedule('15 * * * *', async () => {
const n = await runPaymentPendingTimeoutJob()
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob()
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
})
for (const sub of subscriptions) {
const owner = sub.company.employees[0]
if (owner) {
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
})
}
}
})
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
// old overdue log is superseded and notifications stop automatically.
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
orderBy: { performedAt: 'desc' },
})
// Keep only the most-recent log per vehicle+type combination
const latestByKey = new Map<string, typeof allCandidates[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
// Determine date-based urgency
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
// Determine odometer-based urgency
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
// Skip if the latest log is no longer due (owner has updated it)
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Dedup: don't send more than once per day for the same log
const alreadySent = await prisma.notification.findFirst({
where: {
type: 'VEHICLE_MAINTENANCE_DUE',
companyId: company.id,
createdAt: { gte: oneDayAgo },
data: { path: ['maintenanceLogId'], equals: log.id },
},
})
if (alreadySent) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
await prisma.notification.create({
data: {
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channel: 'IN_APP',
status: 'DELIVERED',
},
})
}
})
// ─── Start ────────────────────────────────────────────────────
const PORT = Number(process.env.API_PORT ?? 4000)
const HOST = process.env.API_HOST ?? '0.0.0.0'
@@ -87,44 +268,4 @@ server.listen(PORT, HOST, () => {
console.log(`[API] Server running on ${HOST}:${PORT}`)
})
let shuttingDown = false
async function shutdown(signal: string) {
if (shuttingDown) return
shuttingDown = true
console.log(`[API] ${signal} received, draining`)
server.close((err) => {
if (err) console.error('[API] HTTP close error:', err.message)
})
try {
io.close()
} catch (err: any) {
console.error('[API] Socket.IO close error:', err?.message ?? err)
}
try {
await subscriber.quit()
} catch {
subscriber.disconnect()
}
try {
await redis.quit()
} catch {
redis.disconnect()
}
try {
await prisma.$disconnect()
} catch (err: any) {
console.error('[API] Prisma disconnect error:', err?.message ?? err)
}
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
export { app, io }
+3 -2
View File
@@ -15,20 +15,21 @@ describe('emailTranslations', () => {
expect(signupEmail.subject('ar')).toContain('جاهزة')
})
it('renders localized signup text with billing details', () => {
it('renders localized signup text with billing and provider details', () => {
const text = signupEmail.text({
firstName: 'Aya',
companyName: 'Atlas Cars',
plan: 'PRO',
billingPeriod: 'ANNUAL',
currency: 'MAD',
paymentProvider: 'AmanPay',
trialEnd,
}, 'fr')
expect(text).toContain('Bonjour Aya')
expect(text).toContain('Atlas Cars')
expect(text).toContain('Forfait : PRO (annuel)')
expect(text).toContain('Paiements : virement bancaire ou chèque.')
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
})
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
+4 -3
View File
@@ -25,6 +25,7 @@ export const signupEmail = {
plan: string
billingPeriod: string
currency: string
paymentProvider: string
trialEnd: Date
}, lang: Lang): string => {
const trialStr = formatDate(opts.trialEnd, lang)
@@ -35,7 +36,7 @@ export const signupEmail = {
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`,
'Payments: bank transfer or check.',
`Primary payment provider: ${opts.paymentProvider}`,
`Free trial ends on ${trialStr}.`,
'',
'Your workspace is ready. Sign in with the email and password you chose during signup.',
@@ -48,7 +49,7 @@ export const signupEmail = {
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
`Devise : ${opts.currency}`,
'Paiements : virement bancaire ou chèque.',
`Fournisseur de paiement principal : ${opts.paymentProvider}`,
`La période d'essai gratuit se termine le ${trialStr}.`,
'',
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
@@ -61,7 +62,7 @@ export const signupEmail = {
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
`العملة: ${opts.currency}`,
'الدفع: تحويل بنكي أو شيك.',
`مزود الدفع الرئيسي: ${opts.paymentProvider}`,
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
'',
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
-25
View File
@@ -1,25 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getIdempotentResult, setIdempotentResult } from './idempotencyStore'
describe('idempotencyStore (memory)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test'
process.env.IDEMPOTENCY_STORE = 'memory'
})
it('returns miss then hit for the same fingerprint', async () => {
const key = `k-${Date.now()}`
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({ kind: 'miss' })
await setIdempotentResult('carplace', key, 'fp1', { id: 'reservation_1' })
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({
kind: 'hit',
result: { id: 'reservation_1' },
})
})
it('detects fingerprint conflicts', async () => {
const key = `conflict-${Date.now()}`
await setIdempotentResult('carplace', key, 'fp1', { id: 'a' })
await expect(getIdempotentResult('carplace', key, 'fp2')).resolves.toEqual({ kind: 'conflict' })
})
})
-56
View File
@@ -1,56 +0,0 @@
import { redis } from '../lib/redis'
const MEMORY = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
const DEFAULT_TTL_SECONDS = 15 * 60
function useMemory() {
return process.env.IDEMPOTENCY_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
export type IdempotencyHit =
| { kind: 'miss' }
| { kind: 'hit'; result: unknown }
| { kind: 'conflict' }
export async function getIdempotentResult(
scope: string,
key: string,
fingerprint: string,
): Promise<IdempotencyHit> {
const redisKey = `idempotency:${scope}:${key}`
if (useMemory()) {
const cached = MEMORY.get(redisKey)
if (!cached || cached.expiresAt <= Date.now()) return { kind: 'miss' }
if (cached.fingerprint !== fingerprint) return { kind: 'conflict' }
return { kind: 'hit', result: cached.result }
}
const raw = await redis.get(redisKey)
if (!raw) return { kind: 'miss' }
try {
const parsed = JSON.parse(raw) as { fingerprint: string; result: unknown }
if (parsed.fingerprint !== fingerprint) return { kind: 'conflict' }
return { kind: 'hit', result: parsed.result }
} catch {
return { kind: 'miss' }
}
}
export async function setIdempotentResult(
scope: string,
key: string,
fingerprint: string,
result: unknown,
ttlSeconds = DEFAULT_TTL_SECONDS,
): Promise<void> {
const redisKey = `idempotency:${scope}:${key}`
const payload = JSON.stringify({ fingerprint, result })
if (useMemory()) {
MEMORY.set(redisKey, { expiresAt: Date.now() + ttlSeconds * 1000, fingerprint, result })
return
}
await redis.set(redisKey, payload, 'EX', ttlSeconds)
}
+3 -3
View File
@@ -69,18 +69,18 @@ const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
city: { maxLength: 85, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
// Group 2: Title Case All
fullAddress: { maxLength: 255, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
// Group 3: Lowercase — email
email: { maxLength: 254, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
-98
View File
@@ -1,98 +0,0 @@
/**
* Optional S3-compatible object storage (MinIO / AWS S3).
* Activated when FILE_STORAGE_DRIVER=s3.
*
* Uses the AWS SDK v3 if installed; otherwise falls back to a clear startup error.
* Add dependency: `@aws-sdk/client-s3`
*/
type S3ClientLike = {
send: (command: unknown) => Promise<unknown>
}
let clientPromise: Promise<S3ClientLike> | null = null
function required(name: string) {
const value = process.env[name]
if (!value) throw new Error(`${name} is required for S3 storage`)
return value
}
async function getClient(): Promise<S3ClientLike> {
if (!clientPromise) {
clientPromise = (async () => {
try {
// Dynamic import keeps local-only installs working without the SDK.
const sdk = await import('@aws-sdk/client-s3')
return new sdk.S3Client({
region: process.env.S3_REGION ?? 'us-east-1',
endpoint: process.env.S3_ENDPOINT || undefined,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
credentials: {
accessKeyId: required('S3_ACCESS_KEY_ID'),
secretAccessKey: required('S3_SECRET_ACCESS_KEY'),
},
}) as S3ClientLike
} catch (err: any) {
throw new Error(
`FILE_STORAGE_DRIVER=s3 requires @aws-sdk/client-s3. Install it in apps/api. (${err?.message ?? err})`,
)
}
})()
}
return clientPromise
}
export async function headBucket() {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(new sdk.HeadBucketCommand({ Bucket: required('S3_BUCKET') }))
}
export async function putObject(key: string, body: Buffer, contentType = 'application/octet-stream') {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(
new sdk.PutObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
Body: body,
ContentType: contentType,
}),
)
}
export async function getObjectBuffer(key: string): Promise<Buffer> {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
const result: any = await client.send(
new sdk.GetObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
}),
)
const stream = result.Body
if (!stream) throw new Error('Empty S3 object body')
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
export async function deleteObject(key: string) {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(
new sdk.DeleteObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
}),
)
}
export function publicObjectUrl(key: string) {
const base = (process.env.S3_PUBLIC_BASE_URL || process.env.API_URL || 'http://localhost:4000').replace(/\/$/, '')
if (process.env.S3_PUBLIC_BASE_URL) return `${base}/${key.replace(/^\/+/, '')}`
return `${base}/storage/${key.replace(/^\/+/, '')}`
}
-94
View File
@@ -1,94 +0,0 @@
import type { Request, Response, NextFunction } from 'express'
type CounterKey = string
const counters = new Map<CounterKey, number>()
const latencyMs: number[] = []
const MAX_LATENCY_SAMPLES = 2_000
function bump(key: CounterKey, by = 1) {
counters.set(key, (counters.get(key) ?? 0) + by)
}
export function observeHttpRequest(method: string, route: string, statusCode: number, durationMs: number) {
const normalizedRoute = route || 'unknown'
bump(`http_requests_total{method="${method}",route="${normalizedRoute}",status="${statusCode}"}`)
latencyMs.push(durationMs)
if (latencyMs.length > MAX_LATENCY_SAMPLES) latencyMs.splice(0, latencyMs.length - MAX_LATENCY_SAMPLES)
}
export function observeOutboxProcessed(count: number) {
if (count > 0) bump('notification_outbox_processed_total', count)
}
export function setGauge(name: string, value: number) {
counters.set(`gauge:${name}`, value)
}
export function getGauge(name: string): number {
return counters.get(`gauge:${name}`) ?? 0
}
function percentile(sorted: number[], p: number) {
if (sorted.length === 0) return 0
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx]!
}
export function renderPrometheusText(): string {
const lines: string[] = [
'# HELP http_requests_total Total HTTP requests handled by the API',
'# TYPE http_requests_total counter',
]
for (const [key, value] of counters) {
if (key.startsWith('gauge:')) continue
if (key.startsWith('http_requests_total')) {
lines.push(`${key} ${value}`)
}
}
for (const [key, value] of counters) {
if (key.startsWith('notification_outbox_processed_total')) {
lines.push('# HELP notification_outbox_processed_total Notification outbox events completed')
lines.push('# TYPE notification_outbox_processed_total counter')
lines.push(`notification_outbox_processed_total ${value}`)
}
}
const sorted = [...latencyMs].sort((a, b) => a - b)
lines.push('# HELP http_request_duration_ms HTTP request duration percentiles (recent window)')
lines.push('# TYPE http_request_duration_ms gauge')
lines.push(`http_request_duration_ms{quantile="0.5"} ${percentile(sorted, 50)}`)
lines.push(`http_request_duration_ms{quantile="0.95"} ${percentile(sorted, 95)}`)
lines.push(`http_request_duration_ms{quantile="0.99"} ${percentile(sorted, 99)}`)
lines.push('# HELP notification_outbox_pending Notification outbox rows pending dispatch')
lines.push('# TYPE notification_outbox_pending gauge')
lines.push(`notification_outbox_pending ${getGauge('notification_outbox_pending')}`)
lines.push('# HELP notification_outbox_completed Notification outbox rows with status PUBLISHED (DB gauge)')
lines.push('# TYPE notification_outbox_completed gauge')
lines.push(`notification_outbox_completed ${getGauge('notification_outbox_completed')}`)
lines.push('# HELP process_uptime_seconds Process uptime')
lines.push('# TYPE process_uptime_seconds gauge')
lines.push(`process_uptime_seconds ${process.uptime()}`)
return `${lines.join('\n')}\n`
}
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
const started = Date.now()
res.on('finish', () => {
const route = (req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path) || 'unknown'
observeHttpRequest(req.method, route, res.statusCode, Date.now() - started)
})
next()
}
/** Reset in-memory series (tests only). */
export function resetMetricsForTests() {
counters.clear()
latencyMs.length = 0
}
+8 -98
View File
@@ -38,11 +38,11 @@ function isWithinPath(targetPath: string, parentPath: string): boolean {
export function assertStorageConfiguration(): string {
const storageRoot = getStorageRoot()
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT && getStorageDriver() === 'local') {
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
}
if (process.env.NODE_ENV === 'production' && getStorageDriver() === 'local') {
if (process.env.NODE_ENV === 'production') {
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
if (invalidRoot) {
@@ -50,40 +50,11 @@ export function assertStorageConfiguration(): string {
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
if (process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED === 'true') {
if (process.env.PRIVATE_STORAGE_PERSISTENCE_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed persistent private storage in production.')
}
if (process.env.PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed encryption at rest in production.')
}
}
}
if (getStorageDriver() === 's3') {
for (const key of ['S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
if (!process.env[key]) throw new Error(`${key} is required when FILE_STORAGE_DRIVER=s3`)
}
}
return storageRoot
}
export function getStorageDriver(): 'local' | 's3' {
return process.env.FILE_STORAGE_DRIVER === 's3' ? 's3' : 'local'
}
export async function checkStorageReady(): Promise<void> {
if (getStorageDriver() === 's3') {
const { headBucket } = await import('./objectStorage')
await headBucket()
return
}
const root = assertStorageConfiguration()
fs.mkdirSync(path.join(root, 'public'), { recursive: true })
fs.mkdirSync(path.join(root, 'private'), { recursive: true })
}
function ensureStorageRoot(visibility: StorageVisibility): string {
assertStorageConfiguration()
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
@@ -94,7 +65,6 @@ function ensureStorageRoot(visibility: StorageVisibility): string {
function inferVisibility(folder: string): StorageVisibility {
const normalized = folder.replace(/\\/g, '/')
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
if (/\/reservations\//.test(`/${normalized}/`)) return 'private'
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
@@ -132,24 +102,19 @@ export async function uploadImage(
publicId?: string,
visibility: StorageVisibility = inferVisibility(folder),
): Promise<string> {
const safePublicId = (publicId ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || crypto.randomBytes(16).toString('hex')
const filename = `${safePublicId}.jpg`
if (getStorageDriver() === 's3') {
const objectKey = path.posix.join(visibility, folder.replace(/\\/g, '/'), filename)
const { putObject } = await import('./objectStorage')
await putObject(objectKey, buffer, 'image/jpeg')
// Keep the historical public URL shape so existing clients and resolveStoredFilePath continue to work via API proxy.
return `${getApiBase()}/storage/${folder}/${filename}`
}
const storageRoot = ensureStorageRoot(visibility)
const folderPath = path.join(storageRoot, folder)
if (!isWithinPath(folderPath, storageRoot)) {
throw new Error('Upload path escapes storage root')
}
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}`
}
@@ -175,58 +140,3 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath)
}
}
function normalizePrivateStorageKey(storageKey: string) {
const normalized = storageKey.replace(/\\/g, '/').replace(/^\/+/, '')
if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid private storage key')
}
return normalized
}
export function resolvePrivateDocumentPath(storageKey: string): string {
const root = ensureStorageRoot('private')
const filePath = path.join(root, normalizePrivateStorageKey(storageKey))
if (!isWithinPath(filePath, root)) throw new Error('Private document path escapes storage root')
return filePath
}
export async function storePaymentEvidenceInQuarantine(
buffer: Buffer,
companyId: string,
submissionId: string,
extension: string,
) {
const safeExtension = ['.pdf', '.jpg', '.png'].includes(extension) ? extension : ''
if (!safeExtension) throw new Error('Unsupported private document extension')
const storageKey = path.posix.join(
'payment-evidence',
'quarantine',
companyId,
submissionId,
`${crypto.randomBytes(24).toString('hex')}${safeExtension}`,
)
const filePath = resolvePrivateDocumentPath(storageKey)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, buffer, { mode: 0o600, flag: 'wx' })
return { storageKey, filePath }
}
export async function promotePaymentEvidence(storageKey: string) {
const sourcePath = resolvePrivateDocumentPath(storageKey)
const cleanKey = normalizePrivateStorageKey(storageKey).replace('/quarantine/', '/clean/')
if (cleanKey === storageKey) throw new Error('Only quarantined evidence can be promoted')
const targetPath = resolvePrivateDocumentPath(cleanKey)
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
fs.renameSync(sourcePath, targetPath)
return cleanKey
}
export function readPrivateDocument(storageKey: string): Buffer {
return fs.readFileSync(resolvePrivateDocumentPath(storageKey))
}
export async function deletePrivateDocument(storageKey: string): Promise<void> {
const filePath = resolvePrivateDocumentPath(storageKey)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
+8 -153
View File
@@ -10,133 +10,17 @@ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/
const ALPHANUMERIC_REGEX = /^[A-Za-z0-9]+$/
export const languageSchema = z.enum(['en', 'fr', 'ar'])
export type InputLanguage = z.infer<typeof languageSchema>
const LOCALIZED_TEXT_PATTERNS: Record<InputLanguage, RegExp> = {
en: /^[A-Za-z\s'-]+$/u,
fr: /^[A-Za-zÀ-ÖØ-öø-ÿŒœ\s'-]+$/u,
ar: /^[\u0600-\u06FF\u0750-\u077F\s،؛؟ـ'-]+$/u,
}
const LOCALIZED_ADDRESS_PATTERNS: Record<InputLanguage, RegExp> = {
en: /^[A-Za-z0-9\s,'-]+$/u,
fr: /^[A-Za-z0-9À-ÖØ-öø-ÿŒœ\s,'-]+$/u,
ar: /^[0-9\u0660-\u0669\u0600-\u06FF\u0750-\u077F\s،؛؟ـ,'-]+$/u,
}
function normalizeNfc(value: string) {
return value.trim().normalize('NFC')
}
export function validateLocalizedText(value: string, language: InputLanguage, maxLength: number) {
const normalized = normalizeNfc(value)
return normalized.length >= 1 &&
normalized.length <= maxLength &&
LOCALIZED_TEXT_PATTERNS[language].test(normalized)
}
export function validateLocalizedAddress(value: string, language: InputLanguage, maxLength: number) {
const normalized = normalizeNfc(value)
return normalized.length >= 1 &&
normalized.length <= maxLength &&
LOCALIZED_ADDRESS_PATTERNS[language].test(normalized)
}
export function localizedTextIssue(field: string) {
return `${field} contains characters that do not match the selected language`
}
export function isoDateField() {
return z.string().regex(ISO_DATE_REGEX, { message: 'Use YYYY-MM-DD format' })
}
export function pastOrTodayIsoDateField() {
return isoDateField().refine((value) => value <= new Date().toISOString().slice(0, 10), {
message: 'Date cannot be in the future',
})
}
export function optionalVinField() {
return z.string().optional().transform((value) => {
if (value === undefined || value.trim() === '') return undefined
return value.trim().toUpperCase()
}).pipe(
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' }).optional()
)
}
export function vinField() {
return z.string().trim().transform((value) => value.toUpperCase()).pipe(
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' })
)
}
export function optionalAlphanumericIdField() {
return z.string().optional().transform((value) => {
if (value === undefined || value.trim() === '') return undefined
return value.trim().toUpperCase()
}).pipe(
z.string()
.min(5, { message: 'Minimum 5 characters required' })
.max(30, { message: 'Maximum 30 characters allowed' })
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
.optional()
)
}
export function requiredAlphanumericIdField() {
return z.string()
.trim()
.min(5, { message: 'Minimum 5 characters required' })
.max(30, { message: 'Maximum 30 characters allowed' })
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
.transform((value) => value.toUpperCase())
}
export function countryCodeField() {
return z.string()
.trim()
.length(2, { message: 'Use a valid ISO country code' })
.transform((value) => value.toUpperCase())
.refine((value) => {
try {
return new Intl.DisplayNames(['en'], { type: 'region' }).of(value) !== value
} catch {
return /^[A-Z]{2}$/.test(value)
}
}, { message: 'Use a valid ISO country code' })
}
export function optionalCountryCodeField() {
return z.string().optional().transform((value) => {
if (value === undefined || value.trim() === '') return undefined
return value.trim().toUpperCase()
}).pipe(countryCodeField().optional())
}
// ─── Phone sanitization ───────────────────────────────────────
function sanitizePhone(raw: string): string {
let out = ''
for (const ch of raw) {
if (/[\d\s+().-]/.test(ch)) out += ch
if (/[\d\s+]/.test(ch)) out += ch
}
return out.trim()
}
function normalizeMoroccanPhone(raw: string): string {
const compact = sanitizePhone(raw).replace(/[\s().-]/g, '')
if (!MA_PHONE_REGEX.test(sanitizePhone(raw))) return compact
if (compact.startsWith('+212')) return compact
if (compact.startsWith('00212')) return `+212${compact.slice(5)}`
if (compact.startsWith('0')) return `+212${compact.slice(1)}`
return compact
}
// ─── Required fields ───────────────────────────────────────────
function applyFieldRules(fieldType: FieldType) {
@@ -212,7 +96,6 @@ export function emailField() {
return z
.string()
.min(1, { message: 'Email is required' })
.max(254, { message: 'Maximum 254 characters allowed' })
.trim()
.transform((val: string) => sanitizeAndFormat(val, 'email'))
.pipe(
@@ -226,7 +109,6 @@ export function emailField() {
export function optionalEmailField() {
return z
.string()
.max(254, { message: 'Maximum 254 characters allowed' })
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
@@ -246,55 +128,28 @@ export function phoneField() {
return z
.string()
.min(1, { message: 'Phone number is required' })
.max(20, { message: 'Maximum 20 characters allowed' })
.trim()
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
.transform((val: string) => normalizeMoroccanPhone(val))
.transform((val: string) => sanitizePhone(val))
.pipe(
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
z.string().refine(
(val: string) => MA_PHONE_REGEX.test(val),
{ message: 'Please enter a valid Morocco phone number' }
)
)
}
export function optionalPhoneField() {
return z
.string()
.max(20, { message: 'Maximum 20 characters allowed' })
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return normalizeMoroccanPhone(val)
return sanitizePhone(val)
})
.pipe(
z.string().optional().refine(
(val) => val === undefined || /^\+212[5-7]\d{8}$/.test(val),
(val) => val === undefined || MA_PHONE_REGEX.test(val),
{ message: 'Please enter a valid Morocco phone number' }
)
)
}
export function optionalContactPhoneField() {
return z
.string()
.max(20, { message: 'Maximum 20 characters allowed' })
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return normalizeMoroccanPhone(val)
})
.pipe(
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' }).optional()
)
}
export function contactPhoneField() {
return z
.string()
.min(1, { message: 'Phone number is required' })
.max(20, { message: 'Maximum 20 characters allowed' })
.trim()
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
.transform((val: string) => normalizeMoroccanPhone(val))
.pipe(
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
)
}
-105
View File
@@ -1,105 +0,0 @@
import type { Request, Response, NextFunction } from 'express'
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const SESSION_COOKIE_PATTERN = /(?:^|;\s*)(?:admin_session|employee_session|renter_session)=/
function configuredOrigins() {
return [
process.env.SITE_ORIGIN,
process.env.DASHBOARD_URL,
process.env.ADMIN_URL,
process.env.CARPLACE_URL,
process.env.HOMEPAGE_URL,
process.env.WEBSITE_URL,
process.env.NEXT_PUBLIC_HOMEPAGE_URL,
process.env.NEXT_PUBLIC_DASHBOARD_URL,
process.env.NEXT_PUBLIC_ADMIN_URL,
process.env.NEXT_PUBLIC_CARPLACE_URL,
process.env.NEXT_PUBLIC_WEBSITE_URL,
process.env.CORS_ORIGINS,
]
.flatMap((value) => (value ?? '').split(','))
.map((value) => value.trim())
.filter(Boolean)
}
function normalizeOrigin(value: string | undefined): string | null {
if (!value) return null
try {
const url = new URL(value)
return url.origin
} catch {
return null
}
}
function originFromReferer(value: string | undefined): string | null {
if (!value) return null
try {
return new URL(value).origin
} catch {
return null
}
}
function isAllowedDevelopmentOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
if (url.protocol !== 'http:') return false
const trustedDevPorts = new Set(['3000', '3001', '3002', '3004', '4000'])
if (!trustedDevPorts.has(url.port)) return false
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
const octets = url.hostname.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false
}
const first = octets[0]!
const second = octets[1]!
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
} catch {
return false
}
}
export function isTrustedBrowserOrigin(origin: string | null) {
if (!origin) return false
const allowed = new Set(configuredOrigins().map(normalizeOrigin).filter((value): value is string => Boolean(value)))
return allowed.has(origin) || isAllowedDevelopmentOrigin(origin)
}
function isCookieAuthenticatedBrowserMutation(req: Request) {
if (!MUTATING_METHODS.has(req.method.toUpperCase())) return false
const cookie = req.headers.cookie ?? ''
if (!SESSION_COOKIE_PATTERN.test(cookie)) return false
const secFetchSite = Array.isArray(req.headers['sec-fetch-site'])
? req.headers['sec-fetch-site'][0]
: req.headers['sec-fetch-site']
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') return true
// Browser cookie-authenticated mutations must present Origin. Referer is a
// fallback for older clients only; API clients should use Bearer tokens.
return true
}
export function requireTrustedOriginForCookieMutations(req: Request, res: Response, next: NextFunction) {
if (!isCookieAuthenticatedBrowserMutation(req)) return next()
const origin = normalizeOrigin(req.headers.origin as string | undefined)
?? originFromReferer(req.headers.referer as string | undefined)
if (!isTrustedBrowserOrigin(origin)) {
return res.status(403).json({
error: 'csrf_origin_rejected',
message: 'Mutating cookie-authenticated requests must come from a trusted application origin.',
statusCode: 403,
})
}
next()
}
@@ -1,26 +0,0 @@
import type { Request, Response, NextFunction } from 'express'
export const SPOOFABLE_FORWARDING_HEADERS = [
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
'x-real-ip',
'forwarded',
'cf-connecting-ip',
'true-client-ip',
'x-client-ip',
]
/**
* Drop client-supplied forwarding headers unless the deployment explicitly
* states that the immediate proxy has already scrubbed and reset them.
*/
export function sanitizeForwardedHeaders(req: Request, _res: Response, next: NextFunction) {
if (process.env.TRUSTED_FORWARD_HEADERS === 'true') return next()
for (const header of SPOOFABLE_FORWARDING_HEADERS) {
delete req.headers[header]
}
next()
}
@@ -31,8 +31,6 @@ describe('rateLimiter middleware configuration', () => {
expect(authLimiter.max).toBe(20)
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
expect(authLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(authLimiter.skip({ method: 'POST' } as any)).toBe(false)
expect(authLimiter.skipSuccessfulRequests).toBe(true)
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
expect(rateLimit).toHaveBeenCalled()
@@ -65,18 +63,7 @@ describe('rateLimiter middleware configuration', () => {
expect(publicLimiter.max).toBe(60)
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
expect(publicLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(adminLimiter.max).toBe(100)
expect(adminLimiter.message.message).toBe('Too many admin requests')
expect(adminLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
})
it('uses a higher API cap and skips preflight requests before authenticated actor limits', async () => {
const { apiLimiter, actorLimiter } = await import('./rateLimiter')
expect(apiLimiter.max).toBe(300)
expect(apiLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(apiLimiter.skip({ method: 'GET' } as any)).toBe(false)
expect(actorLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
})
})
+19 -33
View File
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { RedisRateLimitStore } from './redisRateLimitStore'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
@@ -50,34 +50,28 @@ function getAuthenticatedActorKey(req: Request): string | null {
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
/** express-rate-limit forbids reusing one Store across limiters — unique prefix per limiter. */
function withStore(prefix: string, options: Parameters<typeof rateLimit>[0]) {
return rateLimit({
...options,
store: new RedisRateLimitStore(prefix),
})
}
export const authLimiter = withStore('rl:auth:', {
windowMs: 15 * 60 * 1000,
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
// attempts count toward the cap.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
skipSuccessfulRequests: true,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
export const apiLimiter = withStore('rl:api:', {
windowMs: 60 * 1000,
max: 300,
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 120,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
@@ -88,32 +82,22 @@ export const apiLimiter = withStore('rl:api:', {
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
export const publicLimiter = withStore('rl:public:', {
// Limiter for public carplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
export const webhookLimiter = withStore('rl:webhook:', {
windowMs: 60 * 1000,
max: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
})
export const adminLimiter = withStore('rl:admin:', {
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
@@ -121,12 +105,14 @@ export const adminLimiter = withStore('rl:admin:', {
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
export const actorLimiter = withStore('rl:actor:', {
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
@@ -1,78 +0,0 @@
import type { Store, Options, ClientRateLimitInfo, IncrementResponse } from 'express-rate-limit'
import { redis } from '../lib/redis'
/**
* Redis-backed store for express-rate-limit.
* Uses memory fallback only when explicitly requested (tests) via RATE_LIMIT_STORE=memory.
*/
export class RedisRateLimitStore implements Store {
prefix: string
windowMs = 60_000
#local = new Map<string, { totalHits: number; resetTime: Date }>()
constructor(prefix = 'rl:') {
this.prefix = prefix
}
init(options: Options): void {
this.windowMs = options.windowMs
}
private useMemory() {
return process.env.RATE_LIMIT_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
async get(key: string): Promise<ClientRateLimitInfo | undefined> {
if (this.useMemory()) {
const hit = this.#local.get(key)
if (!hit) return undefined
return { totalHits: hit.totalHits, resetTime: hit.resetTime }
}
const redisKey = `${this.prefix}${key}`
const [count, ttl] = await Promise.all([redis.get(redisKey), redis.pttl(redisKey)])
if (count == null) return undefined
const resetTime = ttl > 0 ? new Date(Date.now() + ttl) : new Date(Date.now() + this.windowMs)
return { totalHits: Number(count), resetTime }
}
async increment(key: string): Promise<IncrementResponse> {
if (this.useMemory()) {
const now = Date.now()
const existing = this.#local.get(key)
if (!existing || existing.resetTime.getTime() <= now) {
const resetTime = new Date(now + this.windowMs)
this.#local.set(key, { totalHits: 1, resetTime })
return { totalHits: 1, resetTime }
}
existing.totalHits += 1
return { totalHits: existing.totalHits, resetTime: existing.resetTime }
}
const redisKey = `${this.prefix}${key}`
const totalHits = await redis.incr(redisKey)
if (totalHits === 1) await redis.pexpire(redisKey, this.windowMs)
const ttl = await redis.pttl(redisKey)
const resetTime = new Date(Date.now() + (ttl > 0 ? ttl : this.windowMs))
return { totalHits, resetTime }
}
async decrement(key: string): Promise<void> {
if (this.useMemory()) {
const existing = this.#local.get(key)
if (existing && existing.totalHits > 0) existing.totalHits -= 1
return
}
const redisKey = `${this.prefix}${key}`
const value = await redis.decr(redisKey)
if (value < 0) await redis.set(redisKey, '0', 'KEEPTTL')
}
async resetKey(key: string): Promise<void> {
if (this.useMemory()) {
this.#local.delete(key)
return
}
await redis.del(`${this.prefix}${key}`)
}
}
@@ -13,7 +13,7 @@ vi.mock('../lib/prisma', () => ({
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
@@ -95,19 +95,18 @@ describe('requireAdminAuth middleware', () => {
expect(next).toHaveBeenCalledTimes(1)
})
it('allows non-enrolled admins through regular admin auth', async () => {
it('blocks non-enrolled admins from privileged routes', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
const admin = { id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false }
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(req.admin).toEqual(admin)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
expect(next).not.toHaveBeenCalled()
})
})
@@ -124,7 +123,7 @@ describe('requireAdminRole middleware', () => {
expect(next).not.toHaveBeenCalled()
})
it('blocks admins without the explicit required admin role', () => {
it('blocks admins below the required rank', () => {
const req = { admin: { role: 'VIEWER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
@@ -134,26 +133,13 @@ describe('requireAdminRole middleware', () => {
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires explicit FINANCE permission',
message: 'This action requires the FINANCE role or higher',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('does not allow SUPPORT to access FINANCE-only routes', () => {
const req = { admin: { role: 'SUPPORT' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('FINANCE' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(next).not.toHaveBeenCalled()
})
it('allows admins explicitly permitted for the required admin role', () => {
it('allows admins at or above the required rank', () => {
const req = { admin: { role: 'ADMIN' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
@@ -164,54 +150,3 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled()
})
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a recently 2FA-verified admin session', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 5 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose 2FA proof is stale', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification has expired; verify again to continue',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
const req = { admin: { id: 'admin_1', totpEnabled: true } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification is required for this session',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
})
+30 -28
View File
@@ -5,12 +5,25 @@ import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const ADMIN_ROLE_ALLOWLIST: Record<AdminRole, readonly AdminRole[]> = {
SUPER_ADMIN: ['SUPER_ADMIN'],
ADMIN: ['SUPER_ADMIN', 'ADMIN'],
SUPPORT: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT'],
FINANCE: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
VIEWER: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'],
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
ADMIN: 4,
SUPPORT: 3,
FINANCE: 2,
VIEWER: 1,
}
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/me',
'/auth/logout',
'/auth/2fa/setup',
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
/**
@@ -36,6 +49,10 @@ export async function requireAdminAuth(req: Request, res: Response, next: NextFu
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
}
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
}
req.admin = admin
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
next()
@@ -50,10 +67,11 @@ export function requireAdminRole(minimumRole: AdminRole) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
const allowedRoles = ADMIN_ROLE_ALLOWLIST[minimumRole] ?? []
const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99
if (!allowedRoles.includes(admin.role)) {
return sendForbidden(res, 'forbidden', `This action requires explicit ${minimumRole} permission`)
if (rank < required) {
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
}
next()
@@ -67,26 +85,10 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
if (!req.adminAuthLast2faAt) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
const maxAgeMs = Number(process.env.ADMIN_FRESH_2FA_MAX_AGE_MS ?? 30 * 60 * 1000)
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA freshness policy is misconfigured')
}
if (Date.now() - req.adminAuthLast2faAt > maxAgeMs) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification has expired; verify again to continue')
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
}
next()
}
export function requireFreshAdmin2FAWhenEnabled(req: Request, res: Response, next: NextFunction) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
if (!admin.totpEnabled) return next()
return requireFreshAdmin2FA(req, res, next)
}
+27 -84
View File
@@ -1,52 +1,50 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAccessLevel, hasAnyAccess, hasFullAccess, hasWriteAccess } from '../modules/subscriptions/subscription.policy'
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const COMPANY_READ_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
const COMPANY_WRITE_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING', 'PAUSED']
function billingUrl() {
return `${process.env.NEXT_PUBLIC_DASHBOARD_URL ?? process.env.DASHBOARD_URL ?? '/dashboard'}/subscription`
}
async function getSubscriptionStatus(companyId: string) {
const subscription = await prisma.subscription.findUnique({
where: { companyId },
select: { status: true },
})
return subscription?.status ?? 'EXPIRED'
}
function blockSubscription(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return sendPaymentRequired(res, error, message, { billingUrl: billingUrl(), ...extra })
}
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
/**
* Read access: allows healthy read-only states, but blocks companies that should
* not have normal application visibility at all.
* Blocks requests for companies with lapsed or unactivated subscriptions.
* Must be applied after `requireTenant`.
*
* Guarantees on success:
* req.company.status is not SUSPENDED or PENDING, and subscription access is not none
*/
export async function requireSubscriptionRead(req: Request, res: Response, next: NextFunction) {
export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (COMPANY_READ_BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription(
if (BLOCKED_STATUSES.includes(company.status)) {
return sendPaymentRequired(
res,
`subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` },
)
}
const subscriptionStatus = await getSubscriptionStatus(company.id)
const subscription = await prisma.subscription.findUnique({
where: { companyId: company.id },
select: { status: true },
})
const subscriptionStatus = subscription?.status ?? 'EXPIRED'
if (!hasAnyAccess(subscriptionStatus)) {
return blockSubscription(res, 'subscription_required', 'Your subscription has ended. Please reactivate to continue.', {
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
})
return sendPaymentRequired(
res,
'subscription_required',
'Your subscription has ended. Please reactivate to continue.',
{
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`,
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
},
)
}
next()
@@ -54,58 +52,3 @@ export async function requireSubscriptionRead(req: Request, res: Response, next:
next(error)
}
}
/**
* Write access: blocks read-only/limited subscription states from mutating data.
*/
export async function requireSubscriptionWrite(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow changes.')
}
const subscriptionStatus = await getSubscriptionStatus(company.id)
if (!hasWriteAccess(subscriptionStatus)) {
return blockSubscription(res, 'subscription_write_required', 'Your subscription is read-only. Reactivate or update billing to make changes.', {
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
})
}
next()
} catch (error) {
next(error)
}
}
/**
* Full access: required for booking/payment/billing-sensitive actions.
*/
export async function requireSubscriptionFull(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow this action.')
}
const subscriptionStatus = await getSubscriptionStatus(company.id)
if (!hasFullAccess(subscriptionStatus)) {
return blockSubscription(res, 'subscription_full_access_required', 'This action requires an active subscription in good standing.', {
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
})
}
next()
} catch (error) {
next(error)
}
}
// Backward-compatible alias. New routes should choose read/write/full explicitly.
export const requireSubscription = requireSubscriptionRead
@@ -3,11 +3,8 @@ import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
collectionsOverrideSchema,
confirmManualPaymentSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
platformBillingSettingsSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
@@ -47,38 +44,10 @@ describe('admin billing schemas', () => {
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('bounds platform billing tax settings', () => {
expect(platformBillingSettingsSchema.parse({ taxRate: 20 })).toEqual({ taxRate: 20 })
expect(platformBillingSettingsSchema.safeParse({ taxRate: -1 }).success).toBe(false)
expect(platformBillingSettingsSchema.safeParse({ taxRate: 101 }).success).toBe(false)
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
it('requires cleared-funds attestation and an idempotency key for manual subscription settlement', () => {
const confirmation = {
submissionId: 'submission_1',
method: 'BANK_TRANSFER',
externalReference: 'BANK TXN 123',
amount: 19900,
receivedAt: '2026-08-09T12:00:00.000Z',
idempotencyKey: crypto.randomUUID(),
fundsVerified: true,
}
expect(confirmManualPaymentSchema.safeParse(confirmation).success).toBe(true)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, fundsVerified: false }).success).toBe(false)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, amount: 0 }).success).toBe(false)
})
it('keeps suspension and notification override controls separate', () => {
const base = { reason: 'Verified finance dispute', expiresAt: '2026-09-01T12:00:00.000Z' }
expect(collectionsOverrideSchema.parse({ ...base, type: 'PAYMENT_DISPUTE', pauseSuspension: true, pauseNotifications: false })).toMatchObject({ pauseSuspension: true, pauseNotifications: false })
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION' }).success).toBe(false)
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION', revisedSuspensionAt: '2026-08-25T12:00:00.000Z' }).success).toBe(true)
})
})
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listBillingAccounts } from './admin.billing.service'
import { prisma } from '../../lib/prisma'
vi.mock('../../lib/prisma', () => ({
prisma: {
subscriptionInvoice: { findMany: vi.fn() },
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
billingAccount: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
billingInvoice: { groupBy: vi.fn(), findFirst: vi.fn(), create: vi.fn() },
billingCreditBalance: { create: vi.fn() },
billingEvent: { create: vi.fn() },
},
}))
vi.mock('../../services/invoicePdfService', () => ({
generateInvoicePdf: vi.fn(),
}))
describe('admin billing service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.subscriptionInvoice.findMany).mockResolvedValue([])
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
vi.mocked(prisma.billingAccount.count).mockResolvedValue(1 as never)
vi.mocked(prisma.billingInvoice.groupBy)
.mockResolvedValueOnce([
{ billingAccountId: 'billing_empty', _count: { _all: 0 }, _sum: { amountDue: 0 } },
{ billingAccountId: 'billing_due', _count: { _all: 1 }, _sum: { amountDue: 14900 } },
] as never)
.mockResolvedValueOnce([
{ status: 'OPEN', _count: { _all: 1 }, _sum: { amountDue: 14900, totalAmount: 14900 } },
] as never)
})
it('demotes duplicate primary billing accounts and lists only the canonical company row', async () => {
vi.mocked(prisma.billingAccount.findMany)
.mockResolvedValueOnce([
{ id: 'billing_empty', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-10T12:00:00.000Z'), creditBalances: [] },
{ id: 'billing_due', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-09T12:00:00.000Z'), creditBalances: [] },
] as never)
.mockResolvedValueOnce([
{
id: 'billing_due',
companyId: 'company_1',
isPrimary: true,
legalName: 'Atlas car',
billingEmail: 'moulay.elabidi@gmail.com',
createdAt: new Date('2026-08-09T12:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas car', email: 'moulay.elabidi@gmail.com', slug: 'atlas-car', status: 'PENDING', subscription: { status: 'PAYMENT_PENDING' } },
creditBalances: [],
invoices: [{ id: 'invoice_1', status: 'OPEN', amountDue: 14900, amountPaid: 0, currency: 'MAD' }],
},
] as never)
const result = await listBillingAccounts({ page: 1, pageSize: 100 })
expect(prisma.billingAccount.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', id: { not: 'billing_due' }, isPrimary: true },
data: { isPrimary: false },
})
expect(prisma.billingAccount.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
where: { isPrimary: true },
}))
expect(result.data).toHaveLength(1)
expect(result.data[0].id).toBe('billing_due')
expect(result.data[0].openBalance).toBe(14900)
})
})
@@ -1,7 +1,6 @@
import { prisma } from '../../lib/prisma'
import { NotFoundError, ValidationError } from '../../http/errors'
import { generateInvoicePdf } from '../../services/invoicePdfService'
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL',
@@ -11,6 +10,7 @@ const BLOCKING_INVOICE_TYPES = new Set([
])
const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
function toSequenceNumber(value: unknown) {
if (typeof value === 'bigint') return Number(value)
if (typeof value === 'number') return value
@@ -85,45 +85,6 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? []
const openBalance = invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
const paidBalance = invoices
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountPaid ?? 0), 0)
const creditBalance = creditBalances.reduce((sum: number, item: any) => sum + (item.balanceAmount ?? 0), 0)
return {
...account,
openBalance,
paidBalance,
creditBalance,
}
}
function getOpenBalance(invoices: any[] = []) {
return invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
}
function chooseCanonicalBillingAccount<T extends { id: string; createdAt?: Date; invoices?: any[] }>(accounts: T[]) {
return [...accounts].sort((a: any, b: any) => {
const openBalanceDelta = getOpenBalance(b.invoices) - getOpenBalance(a.invoices)
if (openBalanceDelta) return openBalanceDelta
const invoiceCountDelta = (b.invoices?.length ?? 0) - (a.invoices?.length ?? 0)
if (invoiceCountDelta) return invoiceCountDelta
return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
})[0]
}
async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null
invoiceId?: string | null
@@ -162,41 +123,11 @@ async function createAuditLog(data: {
}
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
const existingAccounts = await tx.billingAccount.findMany({
const existing = await tx.billingAccount.findFirst({
where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
orderBy: { createdAt: 'desc' },
})
if (existingAccounts.length) {
if (existingAccounts.length > 1) {
const accountIds = existingAccounts.map((account: any) => account.id)
const invoiceGroups = await tx.billingInvoice.groupBy({
by: ['billingAccountId'],
where: { billingAccountId: { in: accountIds } },
_count: { _all: true },
_sum: { amountDue: true },
})
const invoiceGroupByAccount = new Map<string, any>(invoiceGroups.map((group: any) => [group.billingAccountId, group]))
const canonical = chooseCanonicalBillingAccount(existingAccounts.map((account: any) => {
const group = invoiceGroupByAccount.get(account.id)
const invoiceCount = group?._count?._all ?? 0
const amountDue = group?._sum?.amountDue ?? 0
return {
...account,
invoices: Array.from({ length: invoiceCount }, () => ({
status: 'OPEN',
amountDue: Math.floor(amountDue / Math.max(invoiceCount, 1)),
})),
}
}))
await tx.billingAccount.updateMany({
where: { companyId, id: { not: canonical.id }, isPrimary: true },
data: { isPrimary: false },
})
return existingAccounts.find((account: any) => account.id === canonical.id) ?? canonical
}
return existingAccounts[0]
}
if (existing) return existing
const company = await tx.company.findUniqueOrThrow({
where: { id: companyId },
@@ -396,7 +327,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
}
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
const where: any = { isPrimary: true }
const where: any = {}
if (query.q) {
where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } },
@@ -524,7 +455,20 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
}
const data = (accounts as any[]).map((account) => withBillingAccountBalances(account))
const data = (accounts as any[]).map((account) => {
const openBalance = (account.invoices as any[])
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
const paidBalance = (account.invoices as any[])
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
return {
...account,
openBalance,
paidBalance,
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
}
})
return { data, total, stats }
}
@@ -560,15 +504,7 @@ export async function getBillingAccountDetail(companyId: string) {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
manualPaymentSubmissions: {
orderBy: { createdAt: 'desc' },
include: {
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
},
taxRecords: true,
subscriptionUpgradeRequest: { select: { id: true, status: true } },
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
},
@@ -577,7 +513,7 @@ export async function getBillingAccountDetail(companyId: string) {
})
if (!account) throw new NotFoundError('Billing account not found')
return withBillingAccountBalances(account)
return account
}
export async function updateBillingAccount(
@@ -633,25 +569,6 @@ export async function updateBillingAccount(
return updated
}
export function getBillingPlatformSettings() {
return getPlatformBillingSettings()
}
export async function updateBillingPlatformSettings(data: { taxRate: number }, adminId: string, ip?: string) {
const before = await getPlatformBillingSettings()
const updated = await updatePlatformBillingSettings({ taxRate: data.taxRate, updatedBy: adminId })
await createAuditLog({
adminUserId: adminId,
action: 'UPDATE_PLATFORM_BILLING_SETTINGS',
resource: 'PlatformBillingSettings',
resourceId: updated.id,
before,
after: updated,
ipAddress: ip,
})
return updated
}
export async function setDunningPaused(
billingAccountId: string,
paused: boolean,
@@ -781,7 +698,6 @@ export async function createDraftInvoice(
}
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const platformBillingSettings = await getPlatformBillingSettings()
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
@@ -849,11 +765,9 @@ export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: s
})
}
const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0)
const taxBase = Math.max(preTaxBase - autoCreditToApply, 0)
const shouldApplyTax = current.invoiceType !== 'MANUAL'
const { taxRate, taxAmount } = shouldApplyTax
? calculateTaxAmount(taxBase, account.taxExempt, platformBillingSettings.taxRate)
: { taxRate: 0, taxAmount: 0 }
const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0
if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({
@@ -991,9 +905,6 @@ export async function payInvoice(
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Invoice is not payable in its current state')
}
if (current.subscriptionId) {
throw new ValidationError('Subscription invoices require provider confirmation or the dedicated cleared-manual-payment workflow')
}
const amount = data.amount ?? current.amountDue
if (amount <= 0 || amount > current.amountDue) {
@@ -1472,7 +1383,6 @@ export async function getInvoicePdf(invoiceId: string) {
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
@@ -1485,7 +1395,6 @@ export async function getInvoicePdf(invoiceId: string) {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
@@ -1,552 +0,0 @@
import crypto from 'crypto'
import { prisma } from '../../lib/prisma'
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
import { readPrivateDocument } from '../../lib/storage'
import { sendNotification } from '../../services/notificationService'
import { coerceNotificationLocale, type NotificationLocale } from '../../services/notificationLocalizationService'
import { addBillingPeriod, normalizeExternalReference } from '../subscriptions/subscription.manual.service'
const PAYABLE_STATUSES = ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE']
const customerPaymentCopy: Record<NotificationLocale, {
confirmedTitle: string
confirmed: (details: PaymentConfirmationDetails) => string
rejectedTitle: string
rejected: (invoice: string, reason: string) => string
}> = {
en: {
confirmedTitle: 'Subscription payment confirmed',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Payment evidence needs attention',
rejected: (invoice, reason) => `The evidence submitted for invoice ${invoice} was rejected: ${reason}. Upload corrected evidence from Subscription.`,
},
fr: {
confirmedTitle: 'Paiement de labonnement confirmé',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Justificatif de paiement à corriger',
rejected: (invoice, reason) => `Le justificatif de la facture ${invoice} a été refusé : ${reason}. Téléversez un justificatif corrigé depuis Abonnement.`,
},
ar: {
confirmedTitle: 'تم تأكيد دفع الاشتراك',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'مستند الدفع يحتاج إلى تصحيح',
rejected: (invoice, reason) => `تم رفض مستند الفاتورة ${invoice}: ${reason}. حمّل مستنداً مصححاً من صفحة الاشتراك.`,
},
}
type PaymentConfirmationDetails = {
invoice: string
amountPaid: number
currency: string
paymentType?: string | null
paymentReference?: string | null
receivedAt?: Date | string | null
confirmedAt?: Date | string | null
plan?: string | null
billingPeriod?: string | null
periodStart?: Date | string | null
periodEnd?: Date | string | null
}
function fmtMoney(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format((amount ?? 0) / 100)
}
function fmtDate(value?: Date | string | null) {
if (!value) return 'Not set'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check'
return method ?? 'Manual payment'
}
function buildConfirmedPaymentBody(details: PaymentConfirmationDetails) {
return [
'Dear customer,',
'',
'We confirm that your subscription payment has been verified and recorded. Your subscription is now active for the period shown below.',
'',
`Invoice: ${details.invoice}`,
`Amount paid: ${fmtMoney(details.amountPaid, details.currency)}`,
`Payment type: ${paymentMethodLabel(details.paymentType)}`,
details.paymentReference ? `Payment reference: ${details.paymentReference}` : null,
`Funds received/cleared on: ${fmtDate(details.receivedAt)}`,
`Payment confirmed on: ${fmtDate(details.confirmedAt)}`,
`Subscription plan: ${details.plan ?? 'Current plan'}`,
`Billing period: ${details.billingPeriod ?? 'Current billing period'}`,
`Subscription start: ${fmtDate(details.periodStart)}`,
`Subscription end: ${fmtDate(details.periodEnd)}`,
'',
'A PDF copy of the invoice is attached for your records.',
'',
'Regards,',
'RentalDriveGo Finance',
].filter((line): line is string => line !== null).join('\n')
}
async function notifyPaymentResult(data: {
billingAccountId: string
companyId: string
invoiceId: string
invoiceNumber?: string | null
kind: 'confirmed' | 'rejected'
sourceId: string
reason?: string
}) {
const account = await prisma.billingAccount.findUnique({
where: { id: data.billingAccountId },
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
})
if (!account) return
const invoiceRecord = await prisma.billingInvoice.findUnique({
where: { id: data.invoiceId },
include: {
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
},
})
const paymentAttempt = invoiceRecord?.paymentAttempts?.[0] ?? null
const periodStart = invoiceRecord?.subscription?.currentPeriodStart ?? invoiceRecord?.lineItems?.[0]?.periodStart ?? null
const periodEnd = invoiceRecord?.subscription?.currentPeriodEnd ?? invoiceRecord?.lineItems?.[0]?.periodEnd ?? null
for (const contact of account.billingContacts) {
const enabled = account.enabledCommunicationLocales as string[]
const locale = coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
? contact.employee.preferredLanguage
: account.defaultCommunicationLocale,
)
const copy = customerPaymentCopy[locale]
const invoice = data.invoiceNumber ?? data.invoiceId
const details: PaymentConfirmationDetails = {
invoice,
amountPaid: invoiceRecord?.amountPaid ?? paymentAttempt?.amount ?? 0,
currency: invoiceRecord?.currency ?? paymentAttempt?.currency ?? 'MAD',
paymentType: paymentAttempt?.manualMethod ?? invoiceRecord?.collectionMethod ?? invoiceRecord?.paymentProvider ?? 'MANUAL',
paymentReference: paymentAttempt?.externalReference ?? paymentAttempt?.providerPaymentId ?? null,
receivedAt: paymentAttempt?.receivedAt ?? invoiceRecord?.paidAt ?? null,
confirmedAt: paymentAttempt?.confirmedAt ?? invoiceRecord?.paidAt ?? null,
plan: invoiceRecord?.requestedPlan ?? invoiceRecord?.subscription?.plan ?? null,
billingPeriod: invoiceRecord?.requestedBillingPeriod ?? invoiceRecord?.subscription?.billingPeriod ?? null,
periodStart,
periodEnd,
}
await sendNotification({
type: data.kind === 'confirmed' ? 'SUBSCRIPTION_PAYMENT_CONFIRMED' : 'MANUAL_PAYMENT_EVIDENCE_REJECTED',
title: data.kind === 'confirmed' ? copy.confirmedTitle : copy.rejectedTitle,
body: data.kind === 'confirmed' ? copy.confirmed(details) : copy.rejected(invoice, data.reason ?? ''),
companyId: data.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: data.kind === 'confirmed' ? 'subscription.payment_confirmed.v1' : 'subscription.payment_evidence_rejected.v1',
idempotencyKey: `manual-payment:${data.kind}:${data.sourceId}:${contact.id}`,
sourceType: 'manual_payment',
sourceId: data.sourceId,
data: {
invoiceId: data.invoiceId,
amountPaid: details.amountPaid,
currency: details.currency,
paymentType: details.paymentType,
paymentReference: details.paymentReference,
subscriptionStart: details.periodStart,
subscriptionEnd: details.periodEnd,
timezone: account.timezone,
templateVersion: 1,
localizationFallback: false,
...(data.kind === 'confirmed' ? { emailAttachments: [{ type: 'invoice_pdf', invoiceId: data.invoiceId }] } : {}),
},
policy: { mandatory: true },
})
}
}
function requestHash(value: Record<string, unknown>) {
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
export async function listManualPaymentSubmissions(query: { status: string; page: number; pageSize: number }) {
const where = { status: query.status as any }
const [data, total] = await Promise.all([
prisma.manualPaymentSubmission.findMany({
where,
include: {
invoice: { include: { company: true, subscription: true } },
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
orderBy: { submittedAt: 'asc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.manualPaymentSubmission.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getManualPaymentSubmission(submissionId: string, adminId?: string) {
let submission = await prisma.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: {
invoice: {
include: {
company: { select: { id: true, name: true, email: true } },
subscription: true,
billingAccount: true,
lineItems: true,
},
},
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
reviewedByAdmin: { select: { id: true, firstName: true, lastName: true } },
},
})
if (!submission) throw new NotFoundError('Payment submission not found')
if (adminId && submission.status === 'SUBMITTED') {
await prisma.manualPaymentSubmission.updateMany({
where: { id: submission.id, status: 'SUBMITTED' },
data: { status: 'UNDER_REVIEW', reviewedByAdminId: adminId, reviewedAt: new Date() },
})
return getManualPaymentSubmission(submissionId)
}
return submission
}
export async function getAdminPaymentDocument(submissionId: string, documentId: string, adminId: string, ip?: string) {
const document = await prisma.manualPaymentDocument.findFirst({
where: { id: documentId, submissionId, deletedAt: null, scanStatus: 'CLEAN' },
include: { submission: { include: { invoice: true } } },
})
if (!document || document.submission.invoiceId !== document.invoiceId) throw new NotFoundError('Clean payment evidence document not found')
await prisma.auditLog.create({
data: {
adminUserId: adminId,
action: 'VIEW_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentDocument',
resourceId: document.id,
companyId: document.companyId,
ipAddress: ip,
after: { submissionId, invoiceId: document.invoiceId, sha256: document.sha256 },
},
})
return { document, bytes: readPrivateDocument(document.storageKey) }
}
export async function rejectManualPaymentSubmission(submissionId: string, reason: string, adminId: string, ip?: string) {
const updated = await prisma.$transaction(async (tx: any) => {
const current = await tx.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: { invoice: true },
})
if (!current) throw new NotFoundError('Payment submission not found')
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(current.status)) throw new ConflictError('Submission cannot be rejected in its current state')
const reviewedAt = new Date()
const updated = await tx.manualPaymentSubmission.update({
where: { id: current.id },
data: { status: 'REJECTED', rejectionReason: reason, reviewedByAdminId: adminId, reviewedAt },
include: { documents: { where: { deletedAt: null } } },
})
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.invoiceId,
subscriptionId: current.invoice.subscriptionId,
companyId: current.companyId,
eventType: 'payment_evidence.rejected',
source: 'admin',
payload: { submissionId, reason, adminId },
occurredAt: reviewedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'REJECT_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentSubmission',
resourceId: submissionId,
companyId: current.companyId,
before: { status: current.status },
after: { status: 'REJECTED', reason },
ipAddress: ip,
},
})
return { ...updated, invoice: current.invoice }
})
await notifyPaymentResult({
billingAccountId: updated.billingAccountId,
companyId: updated.companyId,
invoiceId: updated.invoiceId,
invoiceNumber: updated.invoice.invoiceNumber,
kind: 'rejected',
sourceId: updated.id,
reason,
})
return updated
}
export async function confirmManualPayment(invoiceId: string, data: {
submissionId: string
method: 'BANK_TRANSFER' | 'CHECK'
externalReference: string
amount: number
receivedAt: string
note?: string
correctionReason?: string
idempotencyKey: string
fundsVerified: true
}, adminId: string, ip?: string) {
const normalizedReference = normalizeExternalReference(data.externalReference)
const normalizedPayload = {
invoiceId,
submissionId: data.submissionId,
method: data.method,
normalizedReference,
amount: data.amount,
receivedAt: new Date(data.receivedAt).toISOString(),
note: data.note ?? null,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
}
const hash = requestHash(normalizedPayload)
const receivedAt = new Date(data.receivedAt)
if (receivedAt.getTime() > Date.now() + 5 * 60 * 1000) throw new ValidationError('Settlement time cannot be in the future')
let collectionsCaseId: string | null = null
try {
const result = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
billingAccount: true,
subscription: true,
legacySubscriptionInvoice: true,
collectionsCase: true,
manualPaymentSubmissions: {
where: { id: data.submissionId },
include: { documents: { where: { deletedAt: null } } },
},
},
})
if (!current) throw new NotFoundError('Invoice not found')
if (current.invoiceType === 'SUBSCRIPTION_UPGRADE') {
throw new ValidationError('Subscription upgrade invoices must be approved through the upgrade review workflow')
}
const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
include: { invoice: true },
})
if (duplicate) {
const metadata = duplicate.metadata as any
if (duplicate.invoiceId !== invoiceId || metadata?.confirmationRequestHash !== hash) {
throw new ConflictError('Idempotency key was already used with a different payment confirmation')
}
return { invoice: duplicate.invoice, paymentAttempt: duplicate, duplicate: true }
}
if (!PAYABLE_STATUSES.includes(current.status)) throw new ConflictError('Invoice is not payable in its current state')
if (current.collectionMethod !== data.method) throw new ValidationError('Payment method must match the invoice collection method')
if (current.currency !== 'MAD') throw new ValidationError('Manual subscription confirmation requires MAD currency')
if (data.amount !== current.amountDue || data.amount !== current.totalAmount - current.amountPaid) {
throw new ConflictError('The full current invoice balance must be confirmed')
}
const submission = current.manualPaymentSubmissions[0]
if (!submission || submission.invoiceId !== current.id || submission.companyId !== current.companyId) {
throw new ValidationError('Submission does not belong to this invoice')
}
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) throw new ConflictError('Evidence is not awaiting review')
if (submission.method !== data.method) throw new ValidationError('Submission method does not match the confirmation method')
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
throw new ValidationError('Every attached evidence document must be clean')
}
if (submission.normalizedSubmittedReference !== normalizedReference && !data.correctionReason) {
throw new ValidationError('A correction reason is required when the confirmed reference differs from the submitted reference')
}
const confirmedAt = new Date()
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
metadata: { source: 'admin_manual_subscription_confirmation', method: data.method },
},
})
const attempt = await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentIntentId: intent.id,
channel: 'OFFLINE',
manualMethod: data.method,
externalReference: data.externalReference,
normalizedExternalReference: normalizedReference,
receivedAt,
confirmedAt,
confirmedByAdminId: adminId,
idempotencyKey: data.idempotencyKey,
note: data.note ?? null,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
attemptedAt: confirmedAt,
metadata: {
source: 'admin_manual_subscription_confirmation',
submissionId: submission.id,
confirmationRequestHash: hash,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
},
},
})
const paid = await tx.billingInvoice.updateMany({
where: { id: current.id, status: { in: PAYABLE_STATUSES }, amountDue: data.amount },
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: confirmedAt },
})
if (paid.count !== 1) throw new ConflictError('Invoice changed while payment was being confirmed')
await tx.manualPaymentSubmission.update({
where: { id: submission.id },
data: {
status: 'APPROVED',
reviewedByAdminId: adminId,
reviewedAt: confirmedAt,
paymentAttemptId: attempt.id,
},
})
if (current.legacySubscriptionInvoice) {
await tx.subscriptionInvoice.update({
where: { id: current.legacySubscriptionInvoice.id },
data: { status: 'PAID', paidAt: confirmedAt, failedAt: null },
})
}
if (!current.subscription) throw new ValidationError('Subscription invoice is missing its subscription')
const period = (current.requestedBillingPeriod ?? current.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
const isRenewal = current.invoiceType === 'SUBSCRIPTION_RENEWAL'
const periodStart = isRenewal
? (current.collectionsCase?.originalExpirationAt ?? current.subscription.currentPeriodEnd ?? confirmedAt)
: confirmedAt
await tx.subscription.update({
where: { id: current.subscription.id },
data: {
plan: current.requestedPlan ?? current.subscription.plan,
billingPeriod: period,
currency: current.currency,
status: 'ACTIVE',
currentPeriodStart: periodStart,
currentPeriodEnd: addBillingPeriod(periodStart, period),
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
if (current.collectionsCase) {
collectionsCaseId = current.collectionsCase.id
await tx.collectionsCase.update({
where: { id: current.collectionsCase.id },
data: {
status: 'RESOLVED',
resolvedAt: confirmedAt,
nextActionAt: null,
resolutionPaymentAttemptId: attempt.id,
},
})
await tx.collectionsCallTask.updateMany({
where: { collectionsCaseId: current.collectionsCase.id, status: 'OPEN' },
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
})
await tx.collectionsEvent.create({
data: {
collectionsCaseId: current.collectionsCase.id,
companyId: current.companyId,
eventType: 'collections.resolved',
idempotencyKey: `payment:${attempt.id}`,
actorType: 'admin',
actorId: adminId,
payload: { paymentAttemptId: attempt.id },
},
})
}
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.id,
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'invoice.paid',
source: 'admin',
payload: { paymentAttemptId: attempt.id, method: data.method, submissionId: submission.id },
occurredAt: confirmedAt,
},
})
await tx.subscriptionEvent.create({
data: {
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'subscription.activated',
source: 'admin',
payload: { invoiceId: current.id, paymentAttemptId: attempt.id },
occurredAt: confirmedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'CONFIRM_MANUAL_SUBSCRIPTION_PAYMENT',
resource: 'BillingInvoice',
resourceId: current.id,
companyId: current.companyId,
before: { status: current.status, amountDue: current.amountDue },
after: { status: 'PAID', amountDue: 0, paymentAttemptId: attempt.id, method: data.method },
note: data.note,
ipAddress: ip,
},
})
const invoice = await tx.billingInvoice.findUniqueOrThrow({
where: { id: current.id },
include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, manualPaymentSubmissions: { include: { documents: true } } },
})
return { invoice, paymentAttempt: attempt, duplicate: false }
}, { isolationLevel: 'Serializable' as any })
if (collectionsCaseId) {
await prisma.notificationOutbox.updateMany({
where: {
status: 'PENDING',
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
},
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
})
}
await notifyPaymentResult({
billingAccountId: result.invoice.billingAccountId,
companyId: result.invoice.companyId,
invoiceId: result.invoice.id,
invoiceNumber: result.invoice.invoiceNumber,
kind: 'confirmed',
sourceId: result.paymentAttempt.id,
})
return result
} catch (error: any) {
if (error?.code === 'P2002' || error?.code === 'P2034') {
throw new ConflictError('The payment reference, idempotency key, or invoice was confirmed concurrently')
}
throw error
}
}
@@ -9,26 +9,13 @@ describe('admin.presenter', () => {
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
passwordResetExpiresAt: new Date('2026-01-01T00:00:00.000Z'),
emailVerificationToken: 'verify-token',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(
presentAdminSession(
{
id: 'admin_1',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
},
'jwt-token',
),
).toEqual({
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
+1 -12
View File
@@ -1,16 +1,5 @@
const ADMIN_SECRET_FIELDS = [
'passwordHash',
'totpSecret',
'passwordResetToken',
'passwordResetExpiresAt',
'emailVerificationToken',
] as const
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
const safe = { ...admin }
for (const field of ADMIN_SECRET_FIELDS) {
delete (safe as Record<string, unknown>)[field]
}
const { passwordHash, totpSecret, ...safe } = admin
return safe
}
@@ -11,7 +11,6 @@ vi.mock('../../lib/prisma', () => ({
}))
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import * as repo from './admin.repo'
describe('admin.repo edge queries', () => {
@@ -63,7 +62,7 @@ describe('admin.repo edge queries', () => {
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: hashPublicAccessToken('reset-token'),
passwordResetToken: 'reset-token',
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
+18 -140
View File
@@ -1,5 +1,4 @@
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
@@ -59,10 +58,6 @@ export function enableAdminTotp(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
export function enableAdminEmail2fa(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true, totpSecret: null } })
}
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => {
@@ -85,18 +80,17 @@ export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
export function setAdminPasswordReset(id: string, tokenHash: string, expiresAt: Date) {
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
data: { passwordResetToken: tokenHash, passwordResetExpiresAt: expiresAt },
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
})
}
export function findAdminByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.adminUser.findFirst({
where: {
passwordResetToken: tokenHash,
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -161,19 +155,12 @@ export async function applyCompanyUpdate(
name: string
slug: string
address?: unknown
brand?: Record<string, unknown> | null
brand?: { paymentMethodsEnabled?: any[] | null } | null
},
) {
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (typeof companyData.slug === 'string') {
companyData.slug = companyData.slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50) || 'company'
}
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown>
@@ -219,6 +206,7 @@ export async function applyCompanyUpdate(
companyId: id,
displayName: body.brand.displayName ?? current.name,
subdomain: body.brand.subdomain ?? current.slug,
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
...body.brand,
} as any,
})
@@ -355,7 +343,6 @@ export function createAdmin(data: {
firstName: string
lastName: string
role: string
preferredLocale: string
passwordHash: string
permissions?: any[]
}) {
@@ -365,7 +352,6 @@ export function createAdmin(data: {
firstName: data.firstName,
lastName: data.lastName,
role: data.role as any,
preferredLocale: data.preferredLocale,
passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined,
},
@@ -384,7 +370,6 @@ export function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
passwordHash?: string
isActive?: boolean
},
@@ -396,7 +381,6 @@ export function updateAdmin(
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
@@ -506,7 +490,7 @@ export function listPlanFeatures() {
})
}
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder?: number }) {
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({
data: {
plan: data.plan,
@@ -516,7 +500,7 @@ export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO' | '
})
}
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder: number }>) {
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({
where: { id },
data,
@@ -578,126 +562,20 @@ export async function listNotificationsPage(query: {
page: number
pageSize: number
}) {
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ'])
const legacyWhere: any = {}
if (query.channel) legacyWhere.channel = query.channel
if (query.status && legacyStatuses.has(query.status)) {
legacyWhere.status = query.status
} else if (query.status) {
legacyWhere.id = '__delivery_status_only__'
}
if (query.companyId) legacyWhere.companyId = query.companyId
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const deliveryWhere: any = {}
if (query.channel) deliveryWhere.channel = query.channel
if (query.companyId) {
deliveryWhere.notificationRecipient = {
notificationEvent: { companyId: query.companyId },
}
}
if (query.status === 'READ') {
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: { not: null },
}
} else if (query.status) {
deliveryWhere.status = query.status
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: null,
}
}
const take = query.page * query.pageSize
const [legacyNotifications, deliveryNotifications, legacyTotal, deliveryTotal] = await Promise.all([
const [data, total] = await Promise.all([
prisma.notification.findMany({
where: legacyWhere,
where,
orderBy: { createdAt: 'desc' },
take,
include: {
company: { select: { id: true, name: true } },
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
},
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
}),
prisma.notificationDelivery.findMany({
where: deliveryWhere,
orderBy: { createdAt: 'desc' },
take,
include: {
notificationRecipient: {
include: {
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
notificationEvent: {
include: {
company: { select: { id: true, name: true } },
},
},
},
},
},
}),
prisma.notification.count({ where: legacyWhere }),
prisma.notificationDelivery.count({ where: deliveryWhere }),
prisma.notification.count({ where }),
])
const legacyRows = legacyNotifications.map((notification: any) => {
const recipient = notification.employee ?? notification.renter ?? null
return {
id: `legacy:${notification.id}`,
notificationId: notification.id,
deliveryId: null,
source: 'LEGACY',
type: notification.type,
title: notification.title,
body: notification.body,
channel: notification.channel,
status: notification.status,
locale: notification.locale,
sentAt: notification.sentAt,
createdAt: notification.createdAt,
company: notification.company,
companyId: notification.companyId,
recipientType: notification.employeeId ? 'EMPLOYEE' : notification.renterId ? 'RENTER' : null,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: notification.employeeId,
renterId: notification.renterId,
}
})
const deliveryRows = deliveryNotifications.map((delivery: any) => {
const recipientRecord = delivery.notificationRecipient
const event = recipientRecord.notificationEvent
const recipient = recipientRecord.employee ?? recipientRecord.renter ?? null
return {
id: `delivery:${delivery.id}`,
notificationId: event.id,
deliveryId: delivery.id,
source: 'DELIVERY',
type: event.type,
title: event.title,
body: event.body,
channel: delivery.channel,
status: recipientRecord.readAt ? 'READ' : delivery.status,
locale: event.locale,
sentAt: delivery.sentAt ?? delivery.deliveredAt ?? delivery.lastAttemptAt,
createdAt: delivery.createdAt,
company: event.company,
companyId: event.companyId,
recipientType: recipientRecord.recipientType,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: recipientRecord.employeeId,
renterId: recipientRecord.renterId,
}
})
const data = [...legacyRows, ...deliveryRows]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
return { data, total: legacyTotal + deliveryTotal }
return { data, total }
}
+4 -170
View File
@@ -1,18 +1,14 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA, requireFreshAdmin2FAWhenEnabled } from '../../middleware/requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import * as manualPaymentsService from './admin.manual-payments.service'
import * as collectionsService from '../subscriptions/subscription.collections.service'
import * as upgradeService from '../subscriptions/subscription.upgrade.service'
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, email2faVerifySchema,
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
@@ -21,15 +17,9 @@ import {
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
upgradeRequestsQuerySchema, upgradeRequestIdParamSchema, upgradeCorrectionSchema, upgradeRejectSchema, approveUpgradePaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -55,8 +45,8 @@ router.post('/auth/login', async (req, res, next) => {
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
clearSessionCookie(res, 'employee')
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
@@ -90,28 +80,12 @@ router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, presentAdminUser(req.admin as any))
})
router.post('/auth/2fa/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.setupTotp(req.admin.id, req.admin.email))
} catch (err) { next(err) }
})
router.post('/auth/2fa/email/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
try {
ok(res, await service.setupEmail2fa(req.admin.id))
} catch (err) { next(err) }
})
router.post('/auth/2fa/email/verify', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
try {
const { code } = parseBody(email2faVerifySchema, req)
const result = await service.verifyEmail2fa(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid email verification code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
} catch (err) { next(err) }
})
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = parseBody(totpVerifySchema, req)
@@ -301,17 +275,6 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
} catch (err) { next(err) }
})
router.get('/notifications/me', requireAdminAuth, async (req, res, next) => {
try { ok(res, await getAdminNotificationInbox(req.admin.id)) } catch (err) { next(err) }
})
router.post('/notifications/me/:id/read', requireAdminAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await markAdminNotificationRead(req.admin.id, id))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
@@ -377,135 +340,6 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await manualPaymentsService.listManualPaymentSubmissions(parseQuery(manualPaymentSubmissionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
ok(res, await manualPaymentsService.getManualPaymentSubmission(submissionId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId/documents/:documentId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(manualPaymentDocumentParamsSchema, req)
const { document, bytes } = await manualPaymentsService.getAdminPaymentDocument(submissionId, documentId, req.admin.id, req.ip)
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', document.detectedMimeType)
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
res.setHeader('Content-Length', bytes.length)
res.setHeader('Cache-Control', 'private, no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.end(bytes)
} catch (err) { next(err) }
})
router.post('/billing/manual-payment-submissions/:submissionId/reject', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
const { reason } = parseBody(rejectManualPaymentSubmissionSchema, req)
ok(res, await manualPaymentsService.rejectManualPaymentSubmission(submissionId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await manualPaymentsService.confirmManualPayment(invoiceId, parseBody(confirmManualPaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/upgrade-requests', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { status } = parseQuery(upgradeRequestsQuerySchema, req)
ok(res, await upgradeService.listAdminUpgradeRequests(status))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/request-correction', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
const { reason } = parseBody(upgradeCorrectionSchema, req)
ok(res, await upgradeService.requestUpgradeCorrection(requestId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/reject-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
const { reason } = parseBody(upgradeRejectSchema, req)
ok(res, await upgradeService.rejectUpgradePayment(requestId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/approve-payment', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.approveUpgradePayment(requestId, parseBody(approveUpgradePaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/retry-activation', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.retryUpgradeActivation(requestId, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/collections/:caseId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.getCollectionsCase(caseId))
} catch (err) { next(err) }
})
router.patch('/billing/collections/:caseId/assignee', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
const { adminId } = parseBody(collectionsAssigneeSchema, req)
ok(res, await collectionsService.assignCollectionsCase(caseId, adminId, req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collection-tasks/:taskId/outcomes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { taskId } = parseParams(collectionTaskIdParamSchema, req)
ok(res, await collectionsService.recordCallOutcome(taskId, parseBody(collectionTaskOutcomeSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.createCollectionsOverride(caseId, parseBody(collectionsOverrideSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides/:overrideId/revoke', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId, overrideId } = parseParams(collectionsOverrideParamsSchema, req)
ok(res, await collectionsService.revokeCollectionsOverride(caseId, overrideId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.getBillingPlatformSettings())
} catch (err) { next(err) }
})
router.patch('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.updateBillingPlatformSettings(parseBody(platformBillingSettingsSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest'
import { notificationsQuerySchema } from './admin.schemas'
describe('admin notification schemas', () => {
it('accepts valid notification filters with pagination defaults', () => {
expect(notificationsQuerySchema.parse({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: '2',
})).toEqual({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: 2,
pageSize: 50,
})
})
it('rejects invalid notification enum filters before querying Prisma', () => {
expect(notificationsQuerySchema.safeParse({ channel: 'FAX' }).success).toBe(false)
expect(notificationsQuerySchema.safeParse({ status: 'BOUNCED' }).success).toBe(false)
})
})
+26 -174
View File
@@ -1,5 +1,4 @@
import { z } from 'zod'
import { contactPhoneField, countryCodeField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
import type {
CarplaceHomepageContent,
CarplaceHomepageHowItWorksStep,
@@ -11,14 +10,14 @@ import type {
} from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase(),
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
recoveryCode: z.string().min(8).max(32).optional(),
})
export const forgotPasswordSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase(),
email: z.string().email().max(255).trim().toLowerCase(),
})
export const resetPasswordSchema = z.object({
@@ -30,10 +29,6 @@ export const totpVerifySchema = z.object({
code: z.string().length(6),
})
export const email2faVerifySchema = z.object({
code: z.string().length(6),
})
export const companiesQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
@@ -58,12 +53,9 @@ export const auditLogQuerySchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
const notificationChannelSchema = z.enum(['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'])
const notificationStatusSchema = z.enum(['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ'])
export const notificationsQuerySchema = z.object({
channel: notificationChannelSchema.optional(),
status: notificationStatusSchema.optional(),
channel: z.string().optional(),
status: z.string().optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
@@ -88,38 +80,21 @@ export const permissionSchema = z.object({
})
export const createAdminSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase(),
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
email: z.string().email().trim().toLowerCase(),
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
preferredLocale: languageSchema.default('en'),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
}).superRefine((data, ctx) => {
if (!validateLocalizedText(data.firstName, data.preferredLocale, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (!validateLocalizedText(data.lastName, data.preferredLocale, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const updateAdminSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase().optional(),
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
email: z.string().email().trim().toLowerCase().optional(),
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
preferredLocale: languageSchema.optional(),
password: z.string().min(8).optional(),
isActive: z.boolean().optional(),
}).superRefine((data, ctx) => {
const language = data.preferredLocale ?? 'en'
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const adminRoleSchema = z.object({
@@ -136,26 +111,20 @@ export const companyStatusSchema = z.object({
})
const nullableString = z.union([z.string(), z.null()]).optional()
const nullableEmail = z.union([z.string().email().max(254).trim().toLowerCase(), z.null()]).optional()
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
export const adminCompanyUpdateSchema = z.object({
company: z.object({
name: z.string().min(1).optional(),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
.max(50)
.optional(),
email: z.string().email().max(254).trim().toLowerCase().optional(), phone: z.union([contactPhoneField(), z.null()]).optional(),
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
email: z.string().email().optional(), phone: nullableString,
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString,
address: z.object({
streetAddress: nullableString,
city: z.union([z.string().trim().max(85), z.null()]).optional(),
country: z.union([countryCodeField(), z.null()]).optional(),
city: nullableString,
country: nullableString,
zipCode: nullableString,
legalName: nullableString,
legalForm: nullableString,
@@ -173,12 +142,12 @@ export const adminCompanyUpdateSchema = z.object({
responsibleRole: nullableString,
responsibleIdentityNumber: nullableString,
responsibleQualification: nullableString,
responsiblePhone: z.union([contactPhoneField(), z.null()]).optional(),
responsiblePhone: nullableString,
responsibleEmail: nullableEmail,
}).optional(),
}).optional(),
subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']).optional(),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.literal('MAD').optional(),
@@ -189,9 +158,9 @@ export const adminCompanyUpdateSchema = z.object({
brand: z.object({
displayName: z.string().min(1).optional(), tagline: nullableString,
subdomain: z.string().min(1).optional(), customDomain: nullableString,
publicEmail: nullableEmail, publicPhone: z.union([contactPhoneField(), z.null()]).optional(), publicAddress: z.union([z.string().trim().max(255), z.null()]).optional(),
publicCity: z.union([z.string().trim().max(85), z.null()]).optional(), publicCountry: z.union([countryCodeField(), z.null()]).optional(), websiteUrl: nullableUrl,
whatsappNumber: z.union([contactPhoneField(), z.null()]).optional(), defaultLocale: languageSchema.optional(),
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.literal('MAD').optional(),
isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
@@ -201,7 +170,7 @@ export const adminCompanyUpdateSchema = z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
lateFeePerHour: z.number().int().nullable().optional(),
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(),
accountingSettings: z.object({
@@ -263,7 +232,7 @@ export const invoiceIdParamSchema = z.object({
export const billingAccountUpdateSchema = z.object({
legalName: z.string().min(1).max(255).optional(),
billingEmail: z.string().email().max(254).trim().toLowerCase().optional(),
billingEmail: z.string().email().optional(),
billingAddress: z.any().optional(),
taxId: z.union([z.string().max(120), z.null()]).optional(),
taxExempt: z.boolean().optional(),
@@ -271,10 +240,6 @@ export const billingAccountUpdateSchema = z.object({
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const platformBillingSettingsSchema = z.object({
taxRate: z.number().min(0).max(100),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
@@ -324,119 +289,6 @@ export const payBillingInvoiceSchema = z.object({
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
const manualPaymentReferenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const manualPaymentSubmissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const manualPaymentDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const manualPaymentSubmissionsQuerySchema = z.object({
status: z.enum(['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED']).default('SUBMITTED'),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const rejectManualPaymentSubmissionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const confirmManualPaymentSchema = z.object({
submissionId: z.string().min(1),
method: z.enum(['BANK_TRANSFER', 'CHECK']),
externalReference: manualPaymentReferenceSchema,
amount: z.number().int().positive(),
receivedAt: z.string().datetime(),
note: z.string().trim().max(500).optional(),
correctionReason: z.string().trim().min(3).max(500).optional(),
idempotencyKey: z.string().uuid(),
fundsVerified: z.literal(true),
})
export const upgradeRequestsQuerySchema = z.object({
status: z.enum([
'DRAFT',
'QUOTED',
'PAYMENT_PENDING',
'PAYMENT_REVIEW',
'CORRECTION_REQUIRED',
'APPROVED',
'ACTIVATED',
'SCHEDULED',
'REJECTED',
'EXPIRED',
'CANCELLED',
'ACTIVATION_FAILED',
'SUPERSEDED',
]).optional(),
})
export const upgradeRequestIdParamSchema = z.object({ requestId: z.string().min(1) })
export const upgradeCorrectionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const upgradeRejectSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const approveUpgradePaymentSchema = z.object({
submissionId: z.string().min(1),
method: z.enum(['BANK_TRANSFER', 'CHECK']),
externalReference: manualPaymentReferenceSchema,
amount: z.number().int().positive(),
receivedAt: z.string().datetime(),
note: z.string().trim().max(500).optional(),
idempotencyKey: z.string().uuid(),
fundsVerified: z.literal(true),
})
export const collectionsQuerySchema = z.object({
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
assignedTo: z.string().optional(),
actionDueBefore: z.string().datetime().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const collectionsCaseIdParamSchema = z.object({ caseId: z.string().min(1) })
export const collectionTaskIdParamSchema = z.object({ taskId: z.string().min(1) })
export const collectionsOverrideParamsSchema = z.object({ caseId: z.string().min(1), overrideId: z.string().min(1) })
export const collectionsAssigneeSchema = z.object({ adminId: z.string().min(1) })
export const collectionTaskOutcomeSchema = z.object({
outcome: z.enum(['CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED']),
note: z.string().trim().max(500).optional(),
promisedPaymentAt: z.string().datetime().optional(),
nextFollowUpAt: z.string().datetime().optional(),
}).superRefine((data, ctx) => {
if (['NO_ANSWER', 'ISSUE_ESCALATED'].includes(data.outcome) && !data.note) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['note'], message: 'A note is required for this outcome' })
}
if (data.outcome === 'PAYMENT_PROMISED' && (!data.promisedPaymentAt || !data.nextFollowUpAt)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['promisedPaymentAt'], message: 'Promised payment and follow-up times are required' })
}
})
export const collectionsOverrideSchema = z.object({
type: z.enum(['PAYMENT_DISPUTE', 'MANUAL_EXTENSION']),
reason: z.string().trim().min(3).max(500),
expiresAt: z.string().datetime(),
revisedSuspensionAt: z.string().datetime().optional(),
pauseSuspension: z.boolean().default(true),
pauseNotifications: z.boolean().default(false),
}).superRefine((data, ctx) => {
if (data.type === 'MANUAL_EXTENSION' && !data.revisedSuspensionAt) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['revisedSuspensionAt'], message: 'A manual extension requires a revised suspension time' })
}
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
@@ -461,14 +313,14 @@ export const homepageUpdateSchema = z.object({
export const pricingUpdateSchema = z.object({
entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(),
})).min(1),
})
const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0),
})
@@ -481,7 +333,7 @@ export const promotionCreateSchema = z.object({
description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(),
@@ -495,7 +347,7 @@ export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1)
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({
@@ -1,61 +1,28 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
findAdminByIdOrThrow: vi.fn(),
setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
updateAdminTotpSecret: vi.fn(),
createAuditLog: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
}))
const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({
redis: {
on: vi.fn(),
get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)),
set: vi.fn((key: string, value: string) => {
redisStore.set(key, value)
return Promise.resolve('OK')
}),
del: vi.fn((key: string) => {
const deleted = redisStore.delete(key) ? 1 : 0
return Promise.resolve(deleted)
}),
quit: vi.fn(),
duplicate: vi.fn(),
},
}))
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { forgotPassword, login, setupTotp } from './admin.service'
import { forgotPassword } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
const originalJwtSecret = process.env.JWT_SECRET
beforeEach(() => {
vi.clearAllMocks()
redisStore.clear()
process.env.ADMIN_URL = 'http://localhost:3000/admin'
process.env.JWT_SECRET = 'test-jwt-secret'
})
afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl
process.env.JWT_SECRET = originalJwtSecret
})
it('sends the reset email to the canonical stored admin address', async () => {
@@ -79,66 +46,4 @@ describe('admin.service forgotPassword', () => {
}),
)
})
it('signs in without an email login code when admin 2FA is enabled', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_2',
email: 'admin@example.test',
firstName: 'Amal',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: null,
} as any)
await expect(login('admin@example.test', 'password123')).resolves.toEqual(expect.objectContaining({
token: expect.any(String),
admin: expect.objectContaining({ id: 'admin_2', email: 'admin@example.test', totpEnabled: true }),
}))
expect(sendTransactionalEmail).not.toHaveBeenCalled()
})
it('accepts a previously issued emailed admin login code in the 2FA field', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_3',
email: 'admin3@example.test',
firstName: 'Mina',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: null,
} as any)
const code = '123456'
redisStore.set('admin:email-otp:admin_3', hashPublicAccessToken(code))
const result = await login('admin3@example.test', 'password123', code)
expect(result).toEqual(expect.objectContaining({
token: expect.any(String),
admin: expect.objectContaining({ id: 'admin_3', email: 'admin3@example.test' }),
}))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
})
it('reuses a pending TOTP setup secret so duplicate dev setup calls keep codes valid', async () => {
vi.mocked(repo.findAdminByIdOrThrow).mockResolvedValue({
id: 'admin_4',
email: 'admin4@example.test',
totpEnabled: false,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
const first = await setupTotp('admin_4', 'admin4@example.test')
const second = await setupTotp('admin_4', 'admin4@example.test')
expect(first.secret).toBe('JBSWY3DPEHPK3PXP')
expect(second.secret).toBe('JBSWY3DPEHPK3PXP')
expect(repo.updateAdminTotpSecret).not.toHaveBeenCalled()
})
})
+13 -117
View File
@@ -1,22 +1,16 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import { redis } from '../../lib/redis'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
const ADMIN_EMAIL_OTP_TTL_MINUTES = 10
const ADMIN_EMAIL_OTP_TTL_SECONDS = ADMIN_EMAIL_OTP_TTL_MINUTES * 60
const pendingAdminEmailOtps = new Map<string, { code: string; expiresAt: number }>()
function generateRecoveryCode() {
@@ -62,61 +56,6 @@ function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function generateAdminEmailOtp() {
return crypto.randomInt(100000, 1000000).toString()
}
function adminEmailOtpKey(adminId: string) {
return `admin:email-otp:${adminId}`
}
async function sendAdminEmailOtp(admin: { id: string; email: string; firstName?: string | null }) {
const code = generateAdminEmailOtp()
const codeHash = hashPublicAccessToken(code)
pendingAdminEmailOtps.set(admin.id, {
code: codeHash,
expiresAt: Date.now() + ADMIN_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
})
await redis
.set(adminEmailOtpKey(admin.id), codeHash, 'EX', ADMIN_EMAIL_OTP_TTL_SECONDS)
.catch((err) => console.error('[AdminLoginEmailOtpRedisSet]', err?.message))
await sendTransactionalEmail({
to: admin.email,
subject: 'Your RentalDriveGo admin login code',
html: `<p>Hi ${admin.firstName ?? 'Admin'},</p><p>Your admin login code is <strong>${code}</strong>.</p><p>It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName ?? 'Admin'},\n\nYour admin login code is ${code}.\n\nIt expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminLoginEmailOtp]', err?.message))
}
async function consumeAdminEmailOtp(adminId: string, code: string | undefined) {
if (!code) return false
const codeHash = hashPublicAccessToken(code.trim())
const key = adminEmailOtpKey(adminId)
const persistedHash = await redis
.get(key)
.catch((err) => {
console.error('[AdminLoginEmailOtpRedisGet]', err?.message)
return null
})
if (persistedHash) {
if (persistedHash !== codeHash) return false
await redis.del(key).catch((err) => console.error('[AdminLoginEmailOtpRedisDel]', err?.message))
pendingAdminEmailOtps.delete(adminId)
return true
}
const pending = pendingAdminEmailOtps.get(adminId)
if (!pending) return false
if (pending.expiresAt <= Date.now()) {
pendingAdminEmailOtps.delete(adminId)
return false
}
if (pending.code !== codeHash) return false
pendingAdminEmailOtps.delete(adminId)
return true
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
@@ -140,18 +79,19 @@ export async function login(email: string, password: string, totpCode?: string,
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return null
let last2faAt: number | undefined
if (admin.totpEnabled && (totpCode || recoveryCode)) {
const validTotp = totpCode && admin.totpSecret
if (admin.totpEnabled) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validEmailOtp = !validTotp && !admin.totpSecret && await consumeAdminEmailOtp(admin.id, totpCode)
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
const validRecoveryCode = !validTotp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const
last2faAt = Date.now()
if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
await repo.updateAdminLastLogin(admin.id)
@@ -162,47 +102,17 @@ export async function login(email: string, password: string, totpCode?: string,
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id, last2faAt))
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
}
export async function setupTotp(adminId: string, email: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
const secret = admin.totpSecret && !admin.totpEnabled
? admin.totpSecret
: authenticator.generateSecret()
if (secret !== admin.totpSecret) {
await repo.updateAdminTotpSecret(adminId, secret)
}
const secret = authenticator.generateSecret()
await repo.updateAdminTotpSecret(adminId, secret)
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
}
export async function setupEmail2fa(adminId: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
await sendAdminEmailOtp(admin)
return { message: 'Verification code sent.' }
}
export async function verifyEmail2fa(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
const valid = await consumeAdminEmailOtp(adminId, code)
if (!valid) return false
const updated = await repo.enableAdminEmail2fa(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_EMAIL_ENABLED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, ...updated, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function verifyTotp(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
if (!admin.totpSecret) return false
@@ -234,7 +144,7 @@ export async function forgotPassword(email: string) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000)
await repo.setAdminPasswordReset(admin.id, hashPublicAccessToken(rawToken), expiresAt)
await repo.setAdminPasswordReset(admin.id, rawToken, expiresAt)
const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin',
@@ -360,7 +270,7 @@ export async function listAdmins() {
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
@@ -375,7 +285,6 @@ export async function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
password?: string
isActive?: boolean
},
@@ -385,7 +294,6 @@ export async function updateAdmin(
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
})
@@ -433,18 +341,6 @@ export function updateBillingAccount(
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function getBillingPlatformSettings() {
return billingService.getBillingPlatformSettings()
}
export function updateBillingPlatformSettings(
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
adminId: string,
ip?: string,
) {
return billingService.updateBillingPlatformSettings(data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead } from '../../middleware/requireSubscription'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -10,22 +10,22 @@ import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/summary', requireRole('MANAGER'), async (req, res, next) => {
router.get('/summary', async (req, res, next) => {
try {
const { period } = parseQuery(summaryQuerySchema, req)
ok(res, await service.getSummary(req.companyId, period))
} catch (err) { next(err) }
})
router.get('/dashboard', requireRole('MANAGER'), async (req, res, next) => {
router.get('/dashboard', async (req, res, next) => {
try {
ok(res, await service.getDashboard(req.companyId))
} catch (err) { next(err) }
})
router.get('/sources', requireRole('MANAGER'), async (req, res, next) => {
router.get('/sources', async (req, res, next) => {
try {
ok(res, await service.getSources(req.companyId))
} catch (err) { next(err) }
@@ -1,12 +1,12 @@
import { z } from 'zod'
export const summaryQuerySchema = z.object({
period: z.string().trim().max(20).default('30d'),
period: z.string().default('30d'),
})
export const reportQuerySchema = z.object({
from: z.string().trim().max(30).optional(),
to: z.string().trim().max(30).optional(),
format: z.enum(['JSON', 'CSV']).default('JSON'),
period: z.string().trim().max(20).optional(),
from: z.string().optional(),
to: z.string().optional(),
format: z.string().default('JSON'),
period: z.string().optional(),
})
@@ -1,25 +1,13 @@
import { z } from 'zod'
import { localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
/**
* Minimal signup schema — only asks for what's needed to create an identity.
* See progressive-signup-plan.md Section 3.
*/
export const accountStartSchema = z.object({
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
companyName: z.string().trim().min(2).max(120),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
email: z.string().email(),
password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
}).superRefine((account, ctx) => {
if (!validateLocalizedText(account.firstName, account.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (!validateLocalizedText(account.lastName, account.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const companyProfileSchema = z.object({
@@ -54,6 +42,7 @@ export const legalIdentitySchema = z.object({
})
export const paymentSetupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
@@ -65,6 +54,6 @@ export const paymentSetupSchema = z.object({
})
export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
})
@@ -2,8 +2,7 @@ import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
@@ -42,7 +41,7 @@ export async function startAccount(body: AccountStartInput) {
const company = await tx.company.create({
data: {
name: body.companyName,
name: '',
slug,
email: body.email,
address: {},
@@ -53,7 +52,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: body.companyName,
displayName: body.email.split('@')[0] || 'My Workspace',
subdomain: slug,
defaultLocale: body.preferredLanguage,
defaultCurrency: 'MAD',
@@ -63,7 +62,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.subscription.create({
data: {
companyId: company.id,
plan: body.subscriptionPlan ?? 'STARTER',
plan: 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
status: 'TRIALING',
@@ -78,11 +77,11 @@ export async function startAccount(body: AccountStartInput) {
data: {
companyId: company.id,
clerkUserId: `local_owner_${company.id}`,
firstName: body.firstName,
lastName: body.lastName,
firstName: '',
lastName: '',
email: body.email,
passwordHash,
emailVerificationToken: hashPublicAccessToken(verificationToken),
emailVerificationToken: verificationToken,
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
@@ -126,7 +125,8 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(err))
console.error('[AccountStart] Email provider config:', describeEmailProviderConfig())
console.error('[AccountStart] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set', '| pass:', process.env.MAIL_PASSWORD ? '***' : 'not set')
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
})
return {
@@ -32,7 +32,7 @@ type CompanySignupInput = {
responsibleEmail: string
currency: 'MAD'
registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string
@@ -1,10 +1,9 @@
import { z } from 'zod'
import { contactPhoneField, countryCodeField, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
export const companySignupSchema = z.object({
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
firstName: z.string().min(1).max(80),
lastName: z.string().min(1).max(80),
email: z.string().email(),
password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120),
legalName: z.string().min(2).max(160),
@@ -16,10 +15,10 @@ export const companySignupSchema = z.object({
operatingLicenseIssuedAt: z.string().min(1).max(40),
operatingLicenseIssuedBy: z.string().min(1).max(160),
streetAddress: z.string().min(1).max(200),
city: z.string().trim().min(1).max(85).transform((value) => value.normalize('NFC')),
country: countryCodeField(),
city: z.string().min(1).max(120),
country: z.string().min(1).max(120),
zipCode: z.string().min(1).max(40),
companyPhone: contactPhoneField(),
companyPhone: z.string().min(1).max(80),
companyEmail: z.string().email(),
fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80),
@@ -29,20 +28,11 @@ export const companySignupSchema = z.object({
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
responsibleQualification: z.string().max(200).optional(),
responsiblePhone: contactPhoneField(),
responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'),
}).superRefine((signup, ctx) => {
if (!validateLocalizedText(signup.firstName, signup.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (!validateLocalizedText(signup.lastName, signup.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
if (!validateLocalizedText(signup.city, signup.preferredLanguage, 85)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['city'], message: localizedTextIssue('City') })
}
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
@@ -45,6 +45,7 @@ const body = {
plan: 'PRO' as const,
billingPeriod: 'MONTHLY' as const,
currency: 'MAD' as const,
paymentProvider: 'PAYPAL' as const,
}
describe('auth.company.service', () => {
@@ -104,6 +105,7 @@ describe('auth.company.service', () => {
templateVariables: expect.objectContaining({
firstName: 'Aya',
companyName: 'Atlas & Desert Cars!!!',
paymentProvider: 'PAYPAL',
}),
}))
})
@@ -100,6 +100,7 @@ export async function signup(body: CompanySignupInput) {
planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt,
},
}).catch(() => [])
@@ -4,14 +4,12 @@ const prismaMock = vi.hoisted(() => ({
employee: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
}))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import * as repo from './auth.employee.repo'
describe('auth.employee.repo query boundaries', () => {
@@ -25,32 +23,26 @@ describe('auth.employee.repo query boundaries', () => {
})
it('looks up employee login emails case-insensitively and includes company context', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
include: { company: true },
take: 2,
})
})
it('only sends forgot-password emails to active employees', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findActiveEmployeeByEmail('agent@example.test')
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
email: { equals: 'agent@example.test', mode: 'insensitive' },
isActive: true,
},
take: 2,
})
})
it('requires unexpired hashed reset tokens for stored-token password reset lookup', async () => {
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
@@ -58,7 +50,7 @@ describe('auth.employee.repo query boundaries', () => {
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: hashPublicAccessToken('reset_123'),
passwordResetToken: 'reset_123',
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
},
})
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
employee: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
},
}))
@@ -14,13 +14,12 @@ import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './aut
describe('auth.employee.repo', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
})
it('looks up employee login email case-insensitively', async () => {
await findEmployeeWithCompanyByEmail('Owner@Example.com')
expect(prisma.employee.findMany).toHaveBeenCalledWith({
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
@@ -28,14 +27,13 @@ describe('auth.employee.repo', () => {
},
},
include: { company: true },
take: 2,
})
})
it('looks up active employee reset email case-insensitively', async () => {
await findActiveEmployeeByEmail('Owner@Example.com')
expect(prisma.employee.findMany).toHaveBeenCalledWith({
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
@@ -43,15 +41,6 @@ describe('auth.employee.repo', () => {
},
isActive: true,
},
take: 2,
})
})
it('fails closed when multiple employees share an email', async () => {
vi.mocked(prisma.employee.findMany).mockResolvedValue([{ id: 'a' }, { id: 'b' }] as never)
await expect(findEmployeeWithCompanyByEmail('shared@example.com')).rejects.toMatchObject({
code: 'ambiguous_employee_email',
statusCode: 409,
})
})
})
@@ -1,5 +1,4 @@
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
export function findEmployeeWithCompanyById(id: string) {
return prisma.employee.findUnique({
@@ -14,8 +13,8 @@ export function findEmployeeById(id: string) {
})
}
export async function findEmployeeWithCompanyByEmail(email: string) {
const matches = await prisma.employee.findMany({
export function findEmployeeWithCompanyByEmail(email: string) {
return prisma.employee.findFirst({
where: {
email: {
equals: email,
@@ -23,21 +22,11 @@ export async function findEmployeeWithCompanyByEmail(email: string) {
},
},
include: { company: true },
take: 2,
})
if (matches.length > 1) {
throw Object.assign(new Error('Multiple employee accounts match this email. Sign in with your company context or contact support.'), {
statusCode: 409,
code: 'ambiguous_employee_email',
})
}
return matches[0] ?? null
}
export async function findActiveEmployeeByEmail(email: string) {
const matches = await prisma.employee.findMany({
export function findActiveEmployeeByEmail(email: string) {
return prisma.employee.findFirst({
where: {
email: {
equals: email,
@@ -45,17 +34,7 @@ export async function findActiveEmployeeByEmail(email: string) {
},
isActive: true,
},
take: 2,
})
if (matches.length > 1) {
throw Object.assign(new Error('Multiple employee accounts match this email. Contact support.'), {
statusCode: 409,
code: 'ambiguous_employee_email',
})
}
return matches[0] ?? null
}
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
@@ -72,23 +51,10 @@ export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'f
})
}
export function updateEmployeeTotpSecret(id: string, secret: string) {
return prisma.employee.update({ where: { id }, data: { totpSecret: secret } })
}
export function enableEmployeeTotp(id: string) {
return prisma.employee.update({ where: { id }, data: { totpEnabled: true } })
}
export function enableEmployeeEmail2fa(id: string) {
return prisma.employee.update({ where: { id }, data: { totpEnabled: true, totpSecret: null } })
}
export function findEmployeeByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: {
passwordResetToken: tokenHash,
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -105,17 +71,16 @@ export function resetPassword(id: string, passwordHash: string) {
})
}
export function setEmailVerificationToken(id: string, tokenHash: string) {
export function setEmailVerificationToken(id: string, token: string) {
return prisma.employee.update({
where: { id },
data: { emailVerificationToken: tokenHash },
data: { emailVerificationToken: token },
})
}
export function findEmployeeByVerificationToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: { emailVerificationToken: tokenHash },
where: { emailVerificationToken: token },
include: { company: true },
})
}
@@ -6,7 +6,6 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
import { getEmployeeMenu } from '../menu/menu.service'
import {
employeeForgotPasswordSchema,
employee2faVerifySchema,
employeeLanguageSchema,
employeeLoginSchema,
employeeResetPasswordSchema,
@@ -31,13 +30,7 @@ router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
const result = await service.login(body)
if ('twoFactorRequired' in result) {
return res.status(401).json({ error: 'two_factor_required', message: '2FA code required', method: result.method, statusCode: 401 })
}
if ('token' in result) {
clearSessionCookie(res, 'admin')
setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
}
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
@@ -47,38 +40,6 @@ router.post('/logout', (_req, res) => {
ok(res, { success: true })
})
router.post('/2fa/setup', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await service.setupTotp(req.employee.id))
} catch (err) { next(err) }
})
router.post('/2fa/verify', requireCompanyAuth, async (req, res, next) => {
try {
const { code } = parseBody(employee2faVerifySchema, req)
const result = await service.verifyTotp(req.employee.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
if ('token' in result) setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/2fa/email/setup', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await service.setupEmail2fa(req.employee.id))
} catch (err) { next(err) }
})
router.post('/2fa/email/verify', requireCompanyAuth, async (req, res, next) => {
try {
const { code } = parseBody(employee2faVerifySchema, req)
const result = await service.verifyEmail2fa(req.employee.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid email verification code', statusCode: 400 })
if ('token' in result) setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
@@ -1,13 +1,12 @@
import { z } from 'zod'
export const employeeLoginSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase(),
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
})
export const employeeForgotPasswordSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase(),
email: z.string().email().max(255).trim().toLowerCase(),
})
export const employeeLanguageSchema = z.object({
@@ -18,7 +17,3 @@ export const employeeResetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
})
export const employee2faVerifySchema = z.object({
code: z.string().length(6),
})

Some files were not shown because too many files have changed in this diff Show More