4 Commits

Author SHA1 Message Date
root f16e1ab26b new merge for develop
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 01:09:15 -04: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
611 changed files with 15624 additions and 28503 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 DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00 JWT_SECRET=placeholder
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=Qwerty0012345 ADMIN_SEED_PASSWORD=placeholder
ADMIN_SEED_FIRST_NAME=Platform ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
@@ -33,13 +33,12 @@ CLERK_SECRET_KEY=placeholder
NODE_ENV=development 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 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 — Resend (primary) with SMTP fallback
# Email — Gmail SMTP for local Docker development # Get your API key at https://resend.com/api-keys
EMAIL_PROVIDER=gmail
RESEND_API_KEY=re_PLACEHOLDER RESEND_API_KEY=re_PLACEHOLDER
EMAIL_FROM=noreply@rentaldrivego.ma EMAIL_FROM=noreply@rentaldrivego.ma
EMAIL_FROM_NAME=RentalDriveGo 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_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
@@ -49,18 +48,3 @@ MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo 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 JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
NODE_ENV=production 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 ───────────────────────────────────────────────────────────────────── # ── Email ─────────────────────────────────────────────────────────────────────
@@ -79,18 +75,18 @@ EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend # Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V #RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail) # Option B — SMTP (Gmail)
# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend. # SMTP fallback (only used if Resend fails or is unconfigured)
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=your-16-character-gmail-app-password MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo MAIL_REPLY_TO_NAME=RentalDriveGo
# ── Firebase push notifications (optional) ──────────────────────────────────── # ── Firebase push notifications (optional) ────────────────────────────────────
# FIREBASE_PROJECT_ID=your-firebase-project-id # FIREBASE_PROJECT_ID=your-firebase-project-id
# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com # 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 # Frontend public URLs
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com 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_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
@@ -45,7 +45,6 @@ CORS_ORIGINS=https://example.com,https://www.example.com
JWT_SECRET=placeholder JWT_SECRET=placeholder
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
SESSION_COOKIE_DOMAIN=.example.com
NODE_ENV=production NODE_ENV=production
# File storage # File storage
+19 -10
View File
@@ -21,9 +21,6 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d 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) ──────────────────────────── # ─── Clerk (Company employee auth) ────────────────────────────
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... 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_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding 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 (Vehicle + brand photos) ──────────────────────
CLOUDINARY_CLOUD_NAME=your-cloud-name 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_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin 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 NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
# Public site is subdomain-based; use this for local dev: # Public site is subdomain-based; use this for local dev:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
@@ -86,19 +97,17 @@ PUBLIC_SITE_DOMAIN=rentaldrivego.ma
DASHBOARD_URL=http://localhost:3000/dashboard DASHBOARD_URL=http://localhost:3000/dashboard
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── # ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
ADMIN_SEED_PASSWORD=Qwerty0012345 ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
ADMIN_SEED_FIRST_NAME=Super ADMIN_SEED_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin ADMIN_SEED_LAST_NAME=Admin
# Email provider: auto, resend, smtp, or gmail. # SMTP fallback (only used if Resend fails or is unconfigured)
# Use a Gmail app password for Gmail SMTP, not your normal Google account password.
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=your-16-character-gmail-app-password MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+177 -400
View File
@@ -1,8 +1,8 @@
name: Build & Push name: Build & Deploy
on: on:
push: push:
branches: [fix_branch] branches: [develop]
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -15,199 +15,68 @@ env:
GIT_SSL_NO_VERIFY: "true" GIT_SSL_NO_VERIFY: "true"
REGISTRY_HOST: 192.168.3.80 REGISTRY_HOST: 192.168.3.80
DEPLOY_REGISTRY_HOST: 10.0.0.4 DEPLOY_REGISTRY_HOST: 10.0.0.4
DEPLOY_SSH_HOST: 10.0.0.1
DOCKERFILE_PATH: Dockerfile.production DOCKERFILE_PATH: Dockerfile.production
DOCKER_PLATFORM: linux/amd64 DOCKER_PLATFORM: linux/amd64
DEPLOY_ROOT: /opt/rentaldrivego DEPLOY_ROOT: /opt/rentaldrivego
jobs: 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: build-image:
name: Build & Push Docker Image name: Build & Push Docker Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: pipeline-tests
outputs: outputs:
image_repository: ${{ steps.image-meta.outputs.repository }} image_repository: ${{ steps.image-meta.outputs.repository }}
docker_image: ${{ steps.image-meta.outputs.full }} docker_image: ${{ steps.image-meta.outputs.full }}
steps: steps:
- name: Check out repository - name: Check out repository
shell: bash shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
CHECKOUT_TOKEN: ${{ gitea.token }}
run: | run: |
set -euo pipefail 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}" if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
mkdir -p "$WORKSPACE" REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
cd "$WORKSPACE"
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}" git init .
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" 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 fi
git config --global --add safe.directory "$WORKSPACE" git rev-parse --short HEAD
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA" - name: Ensure Docker CLI is available
else run: |
git fetch --no-tags --depth=1 origin "$SHA" 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 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 - name: Docker image metadata
id: image-meta id: image-meta
@@ -215,23 +84,16 @@ jobs:
REPO="${{ gitea.repository }}" REPO="${{ gitea.repository }}"
TAG="${{ gitea.sha }}" TAG="${{ gitea.sha }}"
echo "repository=${REPO}" >> "$GITHUB_OUTPUT" echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT" echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT" echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
- name: Check Docker registry credentials - name: Check Docker registry credentials
id: registry-check 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: | run: |
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}" if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
echo "available=true" >> "$GITHUB_OUTPUT" echo "available=true" >> "$GITHUB_OUTPUT"
else 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" echo "available=false" >> "$GITHUB_OUTPUT"
fi fi
@@ -245,243 +107,158 @@ jobs:
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }} SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
run: | run: |
validate_url() { for variable in \
name="$1" NEXT_PUBLIC_API_URL \
value="$2" 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 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 exit 1
fi fi
case "$value" in case "$value" in
http://*|https://*) ;; http://*|https://*) ;;
*) *)
echo "::error::$name must be an absolute URL (got: $value)" echo "::error::$variable must be an absolute URL (got: $value)"
exit 1 exit 1
;; ;;
esac esac
} done
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then - name: Log in to Gitea Container Registry
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace" if: steps.registry-check.outputs.available == 'true'
fi shell: bash
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
env:
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
run: | run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then docker login "$REGISTRY_HOST" \
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host" --username "${{ secrets.REGISTRY_USERNAME }}" \
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
fi
- name: Check SSH tools
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."
exit 1
fi
- 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 }}
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
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
chmod 644 ~/.ssh/known_hosts
- name: Test SSH authentication
env:
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"
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
- name: Sync build context to VPS
env:
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" \
"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" "
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 --password-stdin
- name: Build and push
shell: bash
env:
BUILDKIT_NO_CLIENT_TOKEN: "true"
run: |
set -euo pipefail
OUTPUT_ARG="--load"
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
OUTPUT_ARG="--push"
fi fi
docker build \
--file '$DOCKERFILE_PATH' \ docker buildx build \
--platform '$DOCKER_PLATFORM' \ --file "$DOCKERFILE_PATH" \
--tag '$IMAGE_FULL' \ --platform "$DOCKER_PLATFORM" \
--tag '$IMAGE_LATEST' \ --tag "${{ steps.image-meta.outputs.full }}" \
--tag "${{ steps.image-meta.outputs.latest }}" \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \ --build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \ --build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \ --build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \ --build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \ --build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \ --build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \ --build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \ --build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \ --build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
"$OUTPUT_ARG" \
. .
if [ '$PUSH_IMAGE' = 'true' ]; then
docker push '$IMAGE_FULL'
docker push '$IMAGE_LATEST'
fi
"
- name: Publish deployment files to VPS deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: [build-image]
env: env:
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }} DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
IMAGE_TAG: ${{ gitea.sha }} IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context steps:
VPS_HOST: ${{ secrets.VPS_IP }} - name: Check out repository
shell: bash
run: | run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" set -euo pipefail
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required" 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 exit 1
fi 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 -V
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
- name: Set up SSH key
if: steps.check-creds.outputs.skip == 'false'
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
chmod 644 ~/.ssh/known_hosts
- name: Sync deployment assets
if: steps.check-creds.outputs.skip == 'false'
run: |
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: Run deploy script on VPS
if: steps.check-creds.outputs.skip == 'false'
run: |
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')"
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
set -e set -e
cd '$REMOTE_BUILD_DIR' cd '$DEPLOY_ROOT'
mkdir -p '$DEPLOY_ROOT/scripts' APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
cp docker-compose.production.yml '$DEPLOY_ROOT/docker-compose.production.yml' APP_VERSION='${{ gitea.sha }}' \
cp docker-compose.portainer.production.yml '$DEPLOY_ROOT/docker-compose.portainer.production.yml' IMAGE_TAG='${{ gitea.sha }}' \
cp docker-compose.registry.production.yml '$DEPLOY_ROOT/docker-compose.registry.production.yml' REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
cp traefik.yaml '$DEPLOY_ROOT/traefik.yaml' REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
cp scripts/apply-env-secret-overrides.sh '$DEPLOY_ROOT/scripts/apply-env-secret-overrides.sh' REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
cp scripts/describe-env-values.sh '$DEPLOY_ROOT/scripts/describe-env-values.sh' bash scripts/docker-prod-deploy.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.'
" "
+178 -191
View File
@@ -2,9 +2,9 @@ name: Test
on: on:
push: push:
branches: ["**"] branches: [develop]
pull_request: pull_request:
branches: ["**"] branches: [develop]
concurrency: concurrency:
group: test-${{ gitea.ref }} group: test-${{ gitea.ref }}
@@ -17,52 +17,50 @@ env:
NPM_CONFIG_FETCH_RETRIES: "5" NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000" NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000" NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
NPM_CONFIG_INCLUDE: "optional"
jobs: jobs:
type-check: type-check:
name: Type Check (all packages) name: Type Check (all packages)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -72,6 +70,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run db:generate - run: npm run db:generate
- run: npm run type-check - run: npm run type-check
@@ -80,46 +79,43 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -129,6 +125,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run db:generate - run: npm run db:generate
- run: npm run test:api - run: npm run test:api
@@ -137,46 +134,43 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -186,6 +180,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:homepage - run: npm run test:homepage
@@ -194,46 +189,43 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -243,6 +235,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:carplace - run: npm run test:carplace
@@ -251,46 +244,43 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -300,6 +290,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run test:admin - run: npm run test:admin
dashboard-tests: dashboard-tests:
@@ -307,46 +298,43 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -356,6 +344,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:dashboard - run: npm run test:dashboard
@@ -390,46 +379,43 @@ jobs:
JWT_EXPIRY: 8h JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository
- uses: actions/setup-node@v4 shell: bash
with: run: |
node-version: ${{ env.NODE_VERSION }} 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 - run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies - name: Install dependencies
run: | 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 for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break npm ci --include=optional && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
exit 1 exit 1
fi fi
npm cache verify || true npm cache verify || true
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done 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 - name: Repair Rollup optional dependency on Linux ARM64
run: | run: |
ARCH="$(uname -m)" ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
exit 0 ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
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 for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then if [ "$attempt" = "3" ]; then
@@ -439,6 +425,7 @@ jobs:
sleep "$((attempt * 10))" sleep "$((attempt * 10))"
done done
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi
- run: npm run db:generate - run: npm run db:generate
- run: npm run db:deploy - run: npm run db:deploy
- run: npx prisma db push --schema packages/database/prisma/schema.prisma - run: npx prisma db push --schema packages/database/prisma/schema.prisma
+8 -26
View File
@@ -12,7 +12,7 @@ variables:
# ==================================== # ====================================
# TEST STAGE # TEST STAGE
# Runs on every branch push and merge request # Runs on every push and merge request
# ==================================== # ====================================
api_tests: api_tests:
@@ -26,19 +26,19 @@ api_tests:
- npm run test:api - npm run test:api
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
carplace_tests: storefront_tests:
stage: test stage: test
image: node:20-bookworm image: node:20-bookworm
before_script: before_script:
- npm ci - npm ci
- npm run build --workspace @rentaldrivego/types - npm run build --workspace @rentaldrivego/types
script: script:
- npm run test:carplace - npm run test:storefront
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
admin_tests: admin_tests:
stage: test stage: test
@@ -49,7 +49,7 @@ admin_tests:
- npm run test:admin - npm run test:admin
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
dashboard_tests: dashboard_tests:
stage: test stage: test
@@ -61,18 +61,7 @@ dashboard_tests:
- npm run test:dashboard - npm run test:dashboard
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_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'
integration_tests: integration_tests:
stage: test stage: test
@@ -111,7 +100,7 @@ integration_tests:
- npm run test:api:integration - npm run test:api:integration
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
# ==================================== # ====================================
# BUILD STAGE # BUILD STAGE
@@ -120,13 +109,6 @@ integration_tests:
build_image: build_image:
stage: build stage: build
image: docker:24 image: docker:24
needs:
- api_tests
- carplace_tests
- admin_tests
- dashboard_tests
- homepage_tests
- integration_tests
services: services:
- name: docker:24-dind - name: docker:24-dind
alias: docker 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. - `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. - `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. - `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. - `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
## Design rules applied ## 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` - `admin/src/styles/phase16-tokens.css`
- `dashboard/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` - `scripts/validate-phase16-design.mjs`
- `DESIGN_MIGRATION_REPORT.md` - `DESIGN_MIGRATION_REPORT.md`
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json` - `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
@@ -7,14 +7,14 @@
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages. 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. 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. 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. 7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
8. Added focused tests and implementation documentation. 8. Added focused tests and implementation documentation.
## Deliberately unchanged ## Deliberately unchanged
- Authentication requests and API endpoints - 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 - Language and theme persistence
- Embedded workspace behavior - Embedded workspace behavior
- Authenticated dashboard and admin navigation - Authenticated dashboard and admin navigation
@@ -25,7 +25,7 @@
- Verified all relative and `@/` local imports resolve. - Verified all relative and `@/` local imports resolve.
- Verified every `use client` directive remains the first statement. - Verified every `use client` directive remains the first statement.
- Verified sign-in and create-account import and render `PublicPageLayout`. - 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`. - Verified embedded password-recovery navigation preserves `embedded=1`.
## Validation limitation ## Validation limitation
+7 -7
View File
@@ -11,7 +11,7 @@
"homepage": "retained as embedded Phase 16 source of truth", "homepage": "retained as embedded Phase 16 source of truth",
"admin": "migrated", "admin": "migrated",
"dashboard": "migrated", "dashboard": "migrated",
"carplace": "migrated", "storefront": "migrated",
"api": "unchanged" "api": "unchanged"
}, },
"validation": { "validation": {
@@ -37,37 +37,37 @@
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64" "sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
}, },
{ {
"path": "carplace/src/app/layout.tsx", "path": "storefront/src/app/layout.tsx",
"status": "modified", "status": "modified",
"bytes": 1833, "bytes": 1833,
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27" "sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
}, },
{ {
"path": "carplace/src/app/globals.css", "path": "storefront/src/app/globals.css",
"status": "modified", "status": "modified",
"bytes": 7525, "bytes": 7525,
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6" "sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
}, },
{ {
"path": "carplace/src/components/CarplaceFooter.tsx", "path": "storefront/src/components/StorefrontFooter.tsx",
"status": "modified", "status": "modified",
"bytes": 4632, "bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3" "sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
}, },
{ {
"path": "carplace/src/components/CarplaceHeader.tsx", "path": "storefront/src/components/StorefrontHeader.tsx",
"status": "modified", "status": "modified",
"bytes": 10438, "bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213" "sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
}, },
{ {
"path": "carplace/src/styles/phase16-tokens.css", "path": "storefront/src/styles/phase16-tokens.css",
"status": "added", "status": "added",
"bytes": 4214, "bytes": 4214,
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0" "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", "status": "modified",
"bytes": 13979, "bytes": 13979,
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b" "sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
+1 -1
View File
@@ -7,7 +7,7 @@
], ],
"includedApplications": [ "includedApplications": [
"homepage", "homepage",
"carplace", "storefront",
"dashboard", "dashboard",
"admin", "admin",
"api" "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: 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` - `homepage`
- `carplace` - `storefront`
- `dashboard` - `dashboard`
- `admin` - `admin`
- `api` - `api`
@@ -17,9 +17,9 @@
| Light/dark and Arabic RTL token mappings present | Passed | | Light/dark and Arabic RTL token mappings present | Passed |
| Reduced-motion and forced-colors rules present | Passed | | Reduced-motion and forced-colors rules present | Passed |
| Shared sign-in/create-account public layout retained | 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 | | 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 ## 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 // 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). // port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH) 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 = { const nextConfig = {
basePath: ADMIN_BASE_PATH, basePath: ADMIN_BASE_PATH,
@@ -9,7 +9,6 @@ interface AdminUser {
lastName: string lastName: string
email: string email: string
role: string role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean isActive: boolean
createdAt: string createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[] permissions?: { id: string; resource: string; actions: string[] }[]
@@ -22,7 +21,6 @@ const EMPTY_FORM = {
email: '', email: '',
password: '', password: '',
role: 'SUPPORT', role: 'SUPPORT',
preferredLocale: 'en',
isActive: true, isActive: true,
} }
@@ -70,7 +68,6 @@ export default function AdminUsersPage() {
email: admin.email, email: admin.email,
password: '', password: '',
role: admin.role, role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive, isActive: admin.isActive,
}) })
setError(null) setError(null)
@@ -95,7 +92,6 @@ export default function AdminUsersPage() {
lastName: form.lastName, lastName: form.lastName,
email: form.email, email: form.email,
role: form.role, role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive, isActive: form.isActive,
...(form.password ? { password: form.password } : {}), ...(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> <label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
<input <input
required 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" 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} value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })} 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> <label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
<input <input
required 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" 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} value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })} onChange={(e) => setForm({ ...form, lastName: e.target.value })}
@@ -232,7 +226,6 @@ export default function AdminUsersPage() {
<input <input
type="email" type="email"
required 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" 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} value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: e.target.value })}
@@ -247,7 +240,6 @@ export default function AdminUsersPage() {
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
required={!editingAdminId} required={!editingAdminId}
minLength={editingAdminId ? undefined : 8} 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" 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} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} 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>)} {ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
</div> </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> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label> <label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select <select
File diff suppressed because it is too large Load Diff
@@ -80,6 +80,7 @@ interface CompanyDetail {
terms: string terms: string
fuelPolicyType: string fuelPolicyType: string
lateFeePerHour: number | null lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean signatureRequired: boolean
showTax: boolean showTax: boolean
} | null } | null
@@ -179,6 +180,7 @@ interface FormState {
terms: string terms: string
fuelPolicyType: string fuelPolicyType: string
lateFeePerHour: string lateFeePerHour: string
taxRate: string
signatureRequired: boolean signatureRequired: boolean
showTax: 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 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 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) { function toDateInput(value: string | null | undefined) {
return value ? new Date(value).toISOString().slice(0, 10) : '' return value ? new Date(value).toISOString().slice(0, 10) : ''
} }
@@ -285,7 +275,7 @@ function createFormState(company: CompanyDetail): FormState {
publicCountry: company.brand?.publicCountry ?? '', publicCountry: company.brand?.publicCountry ?? '',
websiteUrl: company.brand?.websiteUrl ?? '', websiteUrl: company.brand?.websiteUrl ?? '',
whatsappNumber: company.brand?.whatsappNumber ?? '', whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'ar', defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD', defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true, isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
}, },
@@ -296,6 +286,7 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '', terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL', fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '', lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true, signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false, showTax: company.contractSettings?.showTax ?? false,
}, },
@@ -327,7 +318,6 @@ export default function AdminCompanyDetailPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null) const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
async function fetchData() { async function fetchData() {
try { try {
@@ -439,6 +429,7 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms, terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType, fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null, lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired, signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax, showTax: form.contractSettings.showTax,
}, },
@@ -575,57 +566,25 @@ export default function AdminCompanyDetailPage() {
))} ))}
</div> </div>
<div className="space-y-6"> <div className="grid gap-6 xl:grid-cols-2">
<div className="overflow-x-auto border-b border-zinc-800"> <section className="panel p-6">
<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"
>
<h2 className="text-base font-semibold">Company</h2> <h2 className="text-base font-semibold">Company</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
<label> <label>
<span className={LABEL_CLASS}>Name</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Slug</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Email</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Phone</span> <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> </label>
<div> <div>
<span className={LABEL_CLASS}>Company status</span> <span className={LABEL_CLASS}>Company status</span>
@@ -633,18 +592,12 @@ export default function AdminCompanyDetailPage() {
</div> </div>
<label> <label>
<span className={LABEL_CLASS}>Subscription payment ref</span> <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> </label>
</div> </div>
</section> </section>
<section <section className="panel p-6">
id="company-tab-subscription"
role="tabpanel"
aria-labelledby="company-tab-trigger-subscription"
hidden={activeTab !== 'subscription'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Subscription</h2> <h2 className="text-base font-semibold">Subscription</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
<label> <label>
@@ -710,34 +663,28 @@ export default function AdminCompanyDetailPage() {
</div> </div>
</section> </section>
<section <section className="panel p-6">
id="company-tab-publicProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-publicProfile"
hidden={activeTab !== 'publicProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Brand and public profile</h2> <h2 className="text-base font-semibold">Brand and public profile</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
<label> <label>
<span className={LABEL_CLASS}>Display name</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Subdomain</span> <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>
<label className="md:col-span-2"> <label className="md:col-span-2">
<span className={LABEL_CLASS}>Tagline</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Public email</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Public phone</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Custom domain</span> <span className={LABEL_CLASS}>Custom domain</span>
@@ -749,11 +696,11 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>WhatsApp</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Locale</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Brand currency</span> <span className={LABEL_CLASS}>Brand currency</span>
@@ -765,15 +712,15 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>City</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Country</span> <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>
<label className="md:col-span-2"> <label className="md:col-span-2">
<span className={LABEL_CLASS}>Address</span> <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>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300"> <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 })} /> <input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
@@ -782,13 +729,7 @@ export default function AdminCompanyDetailPage() {
</div> </div>
</section> </section>
<section <section className="panel p-6">
id="company-tab-legalProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-legalProfile"
hidden={activeTab !== 'legalProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company legal profile</h2> <h2 className="text-base font-semibold">Company legal profile</h2>
<div className="mt-4 grid gap-6"> <div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -798,11 +739,11 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>Manager / owner name</span> <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>
<label> <label>
<span className={LABEL_CLASS}>ZIP / postal code</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Fax</span> <span className={LABEL_CLASS}>Fax</span>
@@ -814,11 +755,11 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>ICE number</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Operating license number</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Operating license issue date</span> <span className={LABEL_CLASS}>Operating license issue date</span>
@@ -857,7 +798,7 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>Identity document number</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Qualification / diploma / experience</span> <span className={LABEL_CLASS}>Qualification / diploma / experience</span>
@@ -865,38 +806,32 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>Responsible phone</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Responsible email</span> <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> </label>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section <section className="panel p-6">
id="company-tab-operations"
role="tabpanel"
aria-labelledby="company-tab-trigger-operations"
hidden={activeTab !== 'operations'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Operations and finance</h2> <h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6"> <div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label> <label>
<span className={LABEL_CLASS}>Legal name</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Registration number</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Tax ID</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Fuel policy type</span> <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> <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 })} /> <input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label> </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"> <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 })} /> <input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required Signature required
@@ -922,7 +861,7 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label className="md:col-span-2"> <label className="md:col-span-2">
<span className={LABEL_CLASS}>Terms</span> <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> </label>
</div> </div>
@@ -960,11 +899,11 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label> <label>
<span className={LABEL_CLASS}>Accountant name</span> <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>
<label> <label>
<span className={LABEL_CLASS}>Accountant email</span> <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>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300"> <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 })} /> <input type="checkbox" checked={form.accountingSettings.autoSendReport} onChange={(e) => updateSection('accountingSettings', { autoSendReport: e.target.checked })} />
@@ -976,13 +915,7 @@ export default function AdminCompanyDetailPage() {
</section> </section>
</div> </div>
<div <div className="grid gap-6 lg:grid-cols-2">
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="space-y-4"> <div className="space-y-4">
<div className="panel p-6 space-y-4"> <div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Company actions</h2> <h2 className="text-base font-semibold">Company actions</h2>
+429 -13
View File
@@ -1,19 +1,435 @@
'use client' '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 ( return (
<div className="mx-auto max-w-3xl px-6 py-16"> <span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-400">Out of scope</p> <span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
<h1 className="mt-3 text-2xl font-semibold text-zinc-100">Per-tenant containers disabled</h1> {label}
<p className="mt-4 text-sm leading-6 text-zinc-400"> </span>
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> function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
<p className="mt-3 text-sm leading-6 text-zinc-500"> const [logs, setLogs] = useState<string>('')
If isolated runtimes become a commercial requirement, they must be rebuilt as a separate const [loading, setLoading] = useState(true)
least-privilege deployment controller never inside the business API. const [tail, setTail] = useState(150)
</p> 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> </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 Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState, type FormEvent } from 'react' import { useEffect, useState } from 'react'
import { import {
AdminLanguageSwitcher, AdminLanguageSwitcher,
AdminThemeSwitcher, AdminThemeSwitcher,
@@ -13,13 +13,12 @@ import { ADMIN_API_BASE } from '@/lib/api'
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext' import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
function buildUnifiedLoginUrl(nextPath: string) { function buildUnifiedLoginUrl(nextPath: string) {
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000') const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const storedTheme = window.localStorage.getItem('rentaldrivego-theme') ?? window.localStorage.getItem('admin-theme')
const theme = storedTheme === 'light' ? 'light' : 'dark'
const params = new URLSearchParams({ 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 = [ const navLinks = [
@@ -40,23 +39,12 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname() const pathname = usePathname()
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
const controller = new AbortController()
fetch(`${ADMIN_API_BASE}/admin/auth/me`, { fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
credentials: 'include', credentials: 'include',
signal: controller.signal,
}) })
.then(async (response) => { .then(async (response) => {
if (cancelled) return if (cancelled) return
@@ -64,29 +52,21 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const json = await response.json().catch(() => null) const json = await response.json().catch(() => null)
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) { if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
redirectToLogin() window.location.replace(buildUnifiedLoginUrl(pathname))
return return
} }
setAdmin(resolvedAdmin) setAdmin(resolvedAdmin)
setReady(true) 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 { } else {
redirectToLogin() window.location.replace(buildUnifiedLoginUrl(pathname))
} }
}) })
.catch((err) => { .catch(() => {
if (err?.name === 'AbortError') return if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
if (!cancelled) redirectToLogin()
}) })
return () => { return () => {
cancelled = true cancelled = true
controller.abort()
} }
}, [pathname]) }, [pathname])
@@ -126,25 +106,11 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} /> <path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg> </svg>
{dict.nav[link.key]} {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> </Link>
) )
})} })}
</nav> </nav>
<div className="px-3 py-4"> <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 <button
onClick={handleLogout} 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" 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> </div>
</aside> </aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main> <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> </div>
</AdminSessionProvider> </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' import { ADMIN_API_BASE } from '@/lib/api'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' 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 MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type CompanyOption = { type CompanyOption = {
@@ -82,7 +82,7 @@ type FormState = {
} }
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT'] 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 ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
const INPUT = const INPUT =
@@ -117,7 +117,7 @@ function emptyForm(): FormState {
isRequired: false, isRequired: false,
isActive: true, isActive: true,
roles: ['OWNER', 'MANAGER', 'AGENT'], roles: ['OWNER', 'MANAGER', 'AGENT'],
plans: ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'], plans: ['STARTER', 'GROWTH', 'PRO'],
companyIds: [], companyIds: [],
} }
} }
@@ -1,22 +1,32 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import {
fetchAdminNotifications,
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
import { ADMIN_API_BASE } from '@/lib/api' import { ADMIN_API_BASE } from '@/lib/api'
interface AdminInboxItem { interface NotificationItem {
id: string id: string
readAt: string | null type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string 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 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> = { const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'text-sky-400 bg-sky-950/40', 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', SENT: 'text-emerald-400 bg-emerald-950/40',
DELIVERED: 'text-emerald-400 bg-emerald-950/40', DELIVERED: 'text-emerald-400 bg-emerald-950/40',
FAILED: 'text-red-400 bg-red-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', 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() { export default function AdminNotificationsPage() {
const [result, setResult] = useState<NotificationsPageResult | null>(null) const [result, setResult] = useState<Paginated | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('') const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('') const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
function loadInbox() { function load(p: number) {
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) {
setLoading(true) setLoading(true)
setError(null) setError(null)
fetchAdminNotifications( const params = new URLSearchParams({ page: String(p), pageSize: '50' })
p, if (filterChannel) params.set('channel', filterChannel)
{ channel: filterChannel, status: filterStatus, companyId: filterCompany }, if (filterStatus) params.set('status', filterStatus)
signal, if (filterCompany) params.set('companyId', filterCompany)
)
.then((data) => setResult(data)) fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
.catch((err) => { credentials: 'include',
if (err instanceof DOMException && err.name === 'AbortError') return cache: 'no-store',
setResult(null)
setError(err instanceof Error ? err.message : 'Failed to load notifications')
})
.finally(() => {
if (!signal?.aborted) setLoading(false)
}) })
.then((r) => r.json())
.then((json) => setResult(json.data ?? null))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
} }
useEffect(() => { useEffect(() => {
const controller = new AbortController()
setPage(1) setPage(1)
load(1, controller.signal) load(1)
loadInbox()
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany]) }, [filterChannel, filterStatus, filterCompany])
function goToPage(p: number) { function goToPage(p: number) {
@@ -98,12 +81,7 @@ export default function AdminNotificationsPage() {
load(p) load(p)
} }
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0 const totalPages = result ? 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()
}
return ( return (
<div className="shell py-8 space-y-6"> <div className="shell py-8 space-y-6">
@@ -122,27 +100,6 @@ export default function AdminNotificationsPage() {
)} )}
</div> </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 */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<select <select
@@ -161,12 +118,6 @@ export default function AdminNotificationsPage() {
<option value="">All statuses</option> <option value="">All statuses</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)} {STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select> </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> </div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</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"> <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">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">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">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">Channel</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</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> </thead>
<tbody className="divide-y divide-zinc-800/60"> <tbody className="divide-y divide-zinc-800/60">
{loading ? ( {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 ? ( ) : !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) => ( ) : result.data.map((item) => (
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors"> <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"> <td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{formatNotificationDate(item.createdAt)} {new Date(item.createdAt).toLocaleString()}
</td> </td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300"> <td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')} {item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
</td> </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"> <td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
{item.type.replaceAll('_', ' ')} {item.type.replaceAll('_', ' ')}
</td> </td>
@@ -223,7 +167,7 @@ export default function AdminNotificationsPage() {
</span> </span>
</td> </td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500"> <td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{formatNotificationDate(item.sentAt)} {item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
</td> </td>
</tr> </tr>
))} ))}
@@ -64,21 +64,19 @@ type PlanFeatureForm = {
// ─── Constants ───────────────────────────────────────────────────────────── // ─── Constants ─────────────────────────────────────────────────────────────
const PLANS = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'] const PLANS = ['STARTER', 'GROWTH', 'PRO']
const PERIODS = ['MONTHLY', 'ANNUAL'] const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record<string, string> = { const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-400', STARTER: 'text-zinc-400',
GROWTH: 'text-sky-400', GROWTH: 'text-sky-400',
PRO: 'text-violet-400', PRO: 'text-violet-400',
ENTERPRISE: 'text-emerald-400',
} }
const PLAN_BADGE: Record<string, string> = { const PLAN_BADGE: Record<string, string> = {
STARTER: 'bg-zinc-800 text-zinc-300', STARTER: 'bg-zinc-800 text-zinc-300',
GROWTH: 'bg-sky-900/40 text-sky-300', GROWTH: 'bg-sky-900/40 text-sky-300',
PRO: 'bg-violet-900/40 text-violet-300', PRO: 'bg-violet-900/40 text-violet-300',
ENTERPRISE: 'bg-emerald-900/40 text-emerald-300',
} }
// ─── Helpers ─────────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────────
@@ -62,7 +62,6 @@ export default function AdminForgotPasswordPage() {
<input <input
type="email" type="email"
required required
maxLength={254}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="admin@rentaldrivego.com" placeholder="admin@rentaldrivego.com"
-30
View File
@@ -154,10 +154,6 @@ html.dark .panel {
color: rgb(28 25 23); color: rgb(28 25 23);
} }
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 { .light .text-zinc-500 {
color: rgb(120 113 108); color: rgb(120 113 108);
} }
@@ -175,30 +171,4 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover { .light .hover\:text-zinc-200:hover {
color: rgb(28 25 23); 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 type { Metadata } from 'next'
import Script from 'next/script'
import { AdminI18nProvider } from '@/components/I18nProvider' import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css' import './globals.css'
@@ -10,14 +9,12 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="ar" dir="rtl" className="dark" suppressHydrationWarning> <html lang="en" className="light" suppressHydrationWarning>
<head> <head>
<Script <script
id="admin-theme-bootstrap"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __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> </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 { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls' import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function AdminLoginPage() { export default async function AdminLoginPage() {
const requestHeaders = await headers() const requestHeaders = await headers()
const cookieStore = await cookies() const dashboardUrl = resolveServerAppUrl(
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
const theme = rawTheme === 'light' ? 'light' : 'dark'
const websiteUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'), requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'), 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'} type={showPassword ? 'text' : 'password'}
required required
minLength={8} minLength={8}
maxLength={128}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
@@ -134,7 +133,6 @@ function AdminResetPasswordContent() {
type={showConfirm ? 'text' : 'password'} type={showConfirm ? 'text' : 'password'}
required required
minLength={8} minLength={8}
maxLength={128}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
+9 -9
View File
@@ -109,12 +109,11 @@ type AdminI18nContext = {
const Context = createContext<AdminI18nContext | null>(null) const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) { export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('ar') const [language, setLanguage] = useState<AdminLanguage>('en')
const [theme, setTheme] = useState<AdminTheme>('dark') const [theme, setTheme] = useState<AdminTheme>('light')
// Skip the very first write so we don't overwrite a stored preference before // Skip the very first write so we don't overwrite a stored preference with
// the hydration read-effect has applied it. // the default 'en' value before the hydration read-effect has applied it.
const skipFirstLangWrite = useRef(true) const skipFirstLangWrite = useRef(true)
const skipFirstThemeWrite = useRef(true)
useEffect(() => { useEffect(() => {
const stored = window.localStorage.getItem('admin-language') const stored = window.localStorage.getItem('admin-language')
@@ -125,6 +124,11 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
if (storedTheme === 'light' || storedTheme === 'dark') { if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme) 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.classList.add(theme)
document.documentElement.style.colorScheme = theme document.documentElement.style.colorScheme = theme
document.body.dataset.theme = 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` document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
window.localStorage.setItem(SHARED_THEME_KEY, theme) window.localStorage.setItem(SHARED_THEME_KEY, theme)
window.localStorage.setItem('admin-theme', 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') 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', () => { 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('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/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 { export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
if (!host || isInternalHost(host)) return fallback if (!host) return fallback
try { try {
const target = new URL(fallback) const target = new URL(fallback)
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { NextRequest } 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')) { if (request.headers.has('x-middleware-subrequest')) {
return new NextResponse('Unsupported internal request header', { status: 400 }) 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", "prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types", "pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "node dist/index.js", "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", "type-check": "tsc --noEmit",
"pretest": "npm run build --workspace @rentaldrivego/types", "pretest": "npm run build --workspace @rentaldrivego/types",
"test": "vitest run", "test": "vitest run",
@@ -37,7 +34,6 @@
"firebase-admin": "^10.3.0", "firebase-admin": "^10.3.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"@aws-sdk/client-s3": "^3.758.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.1.1", "multer": "^2.1.1",
+18 -154
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 cors, { type CorsOptions } from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
import morgan from 'morgan' import morgan from 'morgan'
import swaggerUi from 'swagger-ui-express' import swaggerUi from 'swagger-ui-express'
import { openApiDocument } from './swagger/openapi' import { openApiDocument } from './swagger/openapi'
import { getPublicStorageRoot } from './lib/storage' import { getPublicStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter } from './middleware/rateLimiter' import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
import { requestIdMiddleware } from './middleware/requestId' import { requestIdMiddleware } from './middleware/requestId'
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
// ─── Module routes ──────────────────────────────────────────── // ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes' import webhookRouter from './modules/webhooks/webhook.routes'
import companyAuthRouter from './modules/auth/auth.company.routes' import companyAuthRouter from './modules/auth/auth.company.routes'
import employeeAuthRouter from './modules/auth/auth.employee.routes' import employeeAuthRouter from './modules/auth/auth.employee.routes'
import accountAuthRouter from './modules/auth/auth.account.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 renterAuthRouter from './modules/auth/auth.renter.routes'
import teamRouter from './modules/team/team.routes' import teamRouter from './modules/team/team.routes'
import offersRouter from './modules/offers/offer.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 adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter, { import subscriptionsRouter, {
subscriptionPublicRouter, subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes' } from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes' import paymentsRouter from './modules/payments/payment.routes'
import billingRouter from './modules/billing/billing.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 reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes' import complaintsRouter from './modules/complaints/complaint.routes'
import licenseValidationRouter from './modules/licenses/license.validation.routes' import licenseValidationRouter from './modules/licenses/license.validation.routes'
import searchRouter from './modules/search/search.routes'
// ─── Centralized error handling ─────────────────────────────── // ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware' import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -55,65 +51,20 @@ const defaultCorsOrigins = [
'http://127.0.0.1:4000', 'http://127.0.0.1:4000',
] ]
const frontendOriginEnvKeys = [ export const corsOrigins = process.env.CORS_ORIGINS
'SITE_ORIGIN', ? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
'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 : defaultCorsOrigins
return Array.from(new Set(rawOrigins.map(normalizeConfiguredOrigin).filter((origin): origin is string => Boolean(origin))))
}
export const corsOrigins = getConfiguredCorsOrigins()
function isAllowedLocalDevOrigin(origin: string) { function isAllowedLocalDevOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false if (process.env.NODE_ENV === 'production') return false
try { try {
const url = new URL(origin) const url = new URL(origin)
if (url.protocol !== 'http:') return false return (
if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false url.protocol === 'http:' &&
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true ['localhost', '127.0.0.1'].includes(url.hostname) &&
['3000', '3001', '3002', '4000'].includes(url.port)
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 { } catch {
return false return false
} }
@@ -132,9 +83,7 @@ export const corsOptions: CorsOptions = {
} }
const routeDocs = [ const routeDocs = [
{ method: 'GET', path: '/health', description: 'Liveness health check' }, { method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' }, { 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}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' }, { method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
@@ -145,7 +94,6 @@ const routeDocs = [
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' }, { method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' }, { method: 'GET', path: `${v1}/offers`, description: 'List offers' },
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' }, { 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}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' }, { method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
@@ -167,9 +115,7 @@ export function createApp() {
app.set('trust proxy', 1) app.set('trust proxy', 1)
} }
app.use(sanitizeForwardedHeaders)
app.use(requestIdMiddleware) app.use(requestIdMiddleware)
app.use(metricsMiddleware)
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.headers['x-middleware-subrequest']) { if (req.headers['x-middleware-subrequest']) {
@@ -179,16 +125,12 @@ export function createApp() {
}) })
app.use(corsMiddleware) app.use(corsMiddleware)
app.use(requireTrustedOriginForCookieMutations)
// Customer identity documents must never be anonymously retrievable from the // Customer identity documents must never be anonymously retrievable from the
// public storage mount, even if an older raw storage URL leaks. // public storage mount, even if an older raw storage URL leaks.
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => { app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
res.status(404).end() 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 // 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 // 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. // through authenticated API routes such as /customers/:id/license-image.
app.use('/storage', express.static(getPublicStorageRoot())) app.use('/storage', express.static(getPublicStorageRoot()))
const publicDocsEnabled = process.env.NODE_ENV !== 'production' || process.env.ENABLE_PUBLIC_API_DOCS === 'true' // Swagger UI — mounted before helmet so its assets are not blocked by CSP
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.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument)) app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
} else {
app.use('/docs', docsDisabled)
app.get('/api/v1/openapi.json', docsDisabled)
}
// Webhooks must use raw body BEFORE express.json(); signature verification // Webhooks must use raw body BEFORE express.json(); signature verification
// must never reconstruct the payload with JSON.stringify(req.body). // must never reconstruct the payload with JSON.stringify(req.body).
const removedOnlinePaymentWebhooks = (_req: Request, res: Response) => { app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter)
res.status(404).json({ error: 'not_found', message: 'Online payment webhooks have been removed', statusCode: 404 }) app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' }))
} app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' }))
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)
// Let /storage responses manage CORP explicitly so missing files still return // Let /storage responses manage CORP explicitly so missing files still return
// a normal cross-origin 404 instead of being blocked by Helmet's default // 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' }, referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
frameguard: { action: 'deny' }, frameguard: { action: 'deny' },
})) }))
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
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'),
})
}))
}
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────── // ─── API Routes ─────────────────────────────────────────────
app.use(`${v1}/auth`, authLimiter, unifiedAuthRouter)
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter) app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter) app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter) app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
app.use(`${v1}/admin/auth`, (req, res, next) => { app.use(`${v1}/admin/auth`, authLimiter)
if (req.method === 'GET' && req.path === '/me') return next()
return authLimiter(req, res, next)
})
app.use(`${v1}/admin`, adminLimiter, adminRouter) app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter) app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter) app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter) app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter) app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter) app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
@@ -295,7 +208,6 @@ export function createApp() {
app.use(`${v1}/billing`, apiLimiter, billingRouter) app.use(`${v1}/billing`, apiLimiter, billingRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter) app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter) app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/search`, apiLimiter, searchRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter) app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
// ─── Health / Docs ────────────────────────────────────────── // ─── Health / Docs ──────────────────────────────────────────
@@ -314,55 +226,7 @@ export function createApp() {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() }) 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) => { app.get(`${v1}/docs`, (_req, res) => {
if (!publicDocsEnabled) return docsDisabled(_req, res)
res.json({ res.json({
name: 'rentaldrivego-api', name: 'rentaldrivego-api',
version: '1.0.0', version: '1.0.0',
+2 -48
View File
@@ -2,7 +2,7 @@ import path from 'path'
import multer from 'multer' import multer from 'multer'
import { ValidationError } from '../errors' 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[]>([ const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
['image/jpeg', ['.jpg', '.jpeg']], ['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']], ['image/png', ['.png']],
@@ -16,7 +16,7 @@ const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
*/ */
export const imageUpload = multer({ export const imageUpload = multer({
storage: multer.memoryStorage(), 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 } type DetectedFile = { mime: string; ext: string }
@@ -46,50 +46,6 @@ export function detectImageType(buffer: Buffer): DetectedFile | null {
return 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) { function assertSafeImageContent(file: Express.Multer.File) {
const detected = detectImageType(file.buffer) const detected = detectImageType(file.buffer)
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) { if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
@@ -105,8 +61,6 @@ function assertSafeImageContent(file: Express.Multer.File) {
if (ext && !allowedExtensions.includes(ext)) { if (ext && !allowedExtensions.includes(ext)) {
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`) 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)
}
+190 -49
View File
@@ -1,22 +1,32 @@
import http from 'http' import http from 'http'
import { Server as SocketIOServer } from 'socket.io' import { Server as SocketIOServer } from 'socket.io'
import type { Socket } from 'socket.io' import type { Socket } from 'socket.io'
import cron from 'node-cron'
import { redis } from './lib/redis' import { redis } from './lib/redis'
import { prisma } from './lib/prisma' import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage' import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app' import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens' import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies' 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) const server = http.createServer(app)
assertStorageConfiguration() assertStorageConfiguration()
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, { const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
}) })
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null { function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return 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) => { io.use((socket, next) => {
const token = getSocketSessionToken(socket) const token = getSocketSessionToken(socket)
if (!token) return next() if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try { try {
const payload = verifyAnyActorToken(token) const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub ;(socket as any).authenticatedUserId = payload.sub
@@ -55,9 +66,12 @@ io.use((socket, next) => {
io.on('connection', (socket) => { io.on('connection', (socket) => {
const userId = (socket as any).authenticatedUserId as string | undefined 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() const subscriber = redis.duplicate()
subscriber.psubscribe('notifications:*', (err) => { subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', 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). // ─── Scheduled jobs ───────────────────────────────────────────
// Production should run `npm run worker` / Compose `api-worker` instead.
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') { // Daily: flag expiring/expired licenses
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process') cron.schedule('0 8 * * *', async () => {
startOutboxWorker() const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
startScheduledJobs() 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 PORT = Number(process.env.API_PORT ?? 4000)
const HOST = process.env.API_HOST ?? '0.0.0.0' 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}`) 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 } export { app, io }
+3 -2
View File
@@ -15,20 +15,21 @@ describe('emailTranslations', () => {
expect(signupEmail.subject('ar')).toContain('جاهزة') 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({ const text = signupEmail.text({
firstName: 'Aya', firstName: 'Aya',
companyName: 'Atlas Cars', companyName: 'Atlas Cars',
plan: 'PRO', plan: 'PRO',
billingPeriod: 'ANNUAL', billingPeriod: 'ANNUAL',
currency: 'MAD', currency: 'MAD',
paymentProvider: 'AmanPay',
trialEnd, trialEnd,
}, 'fr') }, 'fr')
expect(text).toContain('Bonjour Aya') expect(text).toContain('Bonjour Aya')
expect(text).toContain('Atlas Cars') expect(text).toContain('Atlas Cars')
expect(text).toContain('Forfait : PRO (annuel)') 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', () => { 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 plan: string
billingPeriod: string billingPeriod: string
currency: string currency: string
paymentProvider: string
trialEnd: Date trialEnd: Date
}, lang: Lang): string => { }, lang: Lang): string => {
const trialStr = formatDate(opts.trialEnd, lang) const trialStr = formatDate(opts.trialEnd, lang)
@@ -35,7 +36,7 @@ export const signupEmail = {
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`, `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`, `Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`, `Currency: ${opts.currency}`,
'Payments: bank transfer or check.', `Primary payment provider: ${opts.paymentProvider}`,
`Free trial ends on ${trialStr}.`, `Free trial ends on ${trialStr}.`,
'', '',
'Your workspace is ready. Sign in with the email and password you chose during signup.', '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.`, `Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`, `Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
`Devise : ${opts.currency}`, `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}.`, `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.", "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} بنجاح.`, `تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`, `الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
`العملة: ${opts.currency}`, `العملة: ${opts.currency}`,
'الدفع: تحويل بنكي أو شيك.', `مزود الدفع الرئيسي: ${opts.paymentProvider}`,
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`, `تنتهي الفترة التجريبية المجانية في ${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 }, name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
nationality: { 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 }, 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 }, pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
returnLocation: { 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 }, country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
// Group 2: Title Case All // 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 }, commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll }, legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
// Group 3: Lowercase — email // 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") // Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, 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 { export function assertStorageConfiguration(): string {
const storageRoot = getStorageRoot() 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.') 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 forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root)) const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
if (invalidRoot) { 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}.` `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 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 { function ensureStorageRoot(visibility: StorageVisibility): string {
assertStorageConfiguration() assertStorageConfiguration()
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot() const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
@@ -94,7 +65,6 @@ function ensureStorageRoot(visibility: StorageVisibility): string {
function inferVisibility(folder: string): StorageVisibility { function inferVisibility(folder: string): StorageVisibility {
const normalized = folder.replace(/\\/g, '/') const normalized = folder.replace(/\\/g, '/')
if (/\/customers\//.test(`/${normalized}/`)) return 'private' if (/\/customers\//.test(`/${normalized}/`)) return 'private'
if (/\/reservations\//.test(`/${normalized}/`)) return 'private'
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private' if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private' if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
if (/\/documents?\//.test(`/${normalized}/`)) return 'private' if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
@@ -132,24 +102,19 @@ export async function uploadImage(
publicId?: string, publicId?: string,
visibility: StorageVisibility = inferVisibility(folder), visibility: StorageVisibility = inferVisibility(folder),
): Promise<string> { ): 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 storageRoot = ensureStorageRoot(visibility)
const folderPath = path.join(storageRoot, folder) const folderPath = path.join(storageRoot, folder)
if (!isWithinPath(folderPath, storageRoot)) { if (!isWithinPath(folderPath, storageRoot)) {
throw new Error('Upload path escapes storage root') throw new Error('Upload path escapes storage root')
} }
fs.mkdirSync(folderPath, { recursive: true }) fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer) fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}` return `${getApiBase()}/storage/${folder}/${filename}`
} }
@@ -175,58 +140,3 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath) 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) */ /** 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 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 ─────────────────────────────────────── // ─── Phone sanitization ───────────────────────────────────────
function sanitizePhone(raw: string): string { function sanitizePhone(raw: string): string {
let out = '' let out = ''
for (const ch of raw) { for (const ch of raw) {
if (/[\d\s+().-]/.test(ch)) out += ch if (/[\d\s+]/.test(ch)) out += ch
} }
return out.trim() 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 ─────────────────────────────────────────── // ─── Required fields ───────────────────────────────────────────
function applyFieldRules(fieldType: FieldType) { function applyFieldRules(fieldType: FieldType) {
@@ -212,7 +96,6 @@ export function emailField() {
return z return z
.string() .string()
.min(1, { message: 'Email is required' }) .min(1, { message: 'Email is required' })
.max(254, { message: 'Maximum 254 characters allowed' })
.trim() .trim()
.transform((val: string) => sanitizeAndFormat(val, 'email')) .transform((val: string) => sanitizeAndFormat(val, 'email'))
.pipe( .pipe(
@@ -226,7 +109,6 @@ export function emailField() {
export function optionalEmailField() { export function optionalEmailField() {
return z return z
.string() .string()
.max(254, { message: 'Maximum 254 characters allowed' })
.optional() .optional()
.transform((val) => { .transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined if (val === undefined || val === null || val.trim() === '') return undefined
@@ -246,55 +128,28 @@ export function phoneField() {
return z return z
.string() .string()
.min(1, { message: 'Phone number is required' }) .min(1, { message: 'Phone number is required' })
.max(20, { message: 'Maximum 20 characters allowed' })
.trim() .trim()
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' }) .transform((val: string) => sanitizePhone(val))
.transform((val: string) => normalizeMoroccanPhone(val))
.pipe( .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() { export function optionalPhoneField() {
return z return z
.string() .string()
.max(20, { message: 'Maximum 20 characters allowed' })
.optional() .optional()
.transform((val) => { .transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined if (val === undefined || val === null || val.trim() === '') return undefined
return normalizeMoroccanPhone(val) return sanitizePhone(val)
}) })
.pipe( .pipe(
z.string().optional().refine( 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' } { 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.max).toBe(20)
expect(authLimiter.windowMs).toBe(15 * 60 * 1000) 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.skipSuccessfulRequests).toBe(true)
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 }) expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
expect(rateLimit).toHaveBeenCalled() expect(rateLimit).toHaveBeenCalled()
@@ -65,18 +63,7 @@ describe('rateLimiter middleware configuration', () => {
expect(publicLimiter.max).toBe(60) expect(publicLimiter.max).toBe(60)
expect(publicLimiter.message.message).toBe('Rate limit exceeded') expect(publicLimiter.message.message).toBe('Rate limit exceeded')
expect(publicLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(adminLimiter.max).toBe(100) expect(adminLimiter.max).toBe(100)
expect(adminLimiter.message.message).toBe('Too many admin requests') 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 type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens' import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies' import { getSessionCookieName } from '../security/sessionCookies'
import { RedisRateLimitStore } from './redisRateLimitStore'
const SESSION_COOKIE_NAMES = [ const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'), 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 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. */ // Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
function withStore(prefix: string, options: Parameters<typeof rateLimit>[0]) { // Successful requests (e.g. GET /me profile reads) are skipped so only failed
return rateLimit({ // attempts count toward the cap.
...options, export const authLimiter = rateLimit({
store: new RedisRateLimitStore(prefix), windowMs: 15 * 60 * 1000, // 15 minutes
})
}
export const authLimiter = withStore('rl:auth:', {
windowMs: 15 * 60 * 1000,
max: 20, max: 20,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
skip: skipPreflightRequest,
skipSuccessfulRequests: true, skipSuccessfulRequests: true,
keyGenerator: (req) => getClientIpKey(req), keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 }, message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
}) })
export const apiLimiter = withStore('rl:api:', { // Standard limiter for general authenticated API endpoints
windowMs: 60 * 1000, export const apiLimiter = rateLimit({
max: 300, windowMs: 60 * 1000, // 1 minute
max: 120,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => { keyGenerator: (req) => {
const ip = getClientIpKey(req) const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(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 }, 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, windowMs: 60 * 1000,
max: 60, max: 60,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => getClientIpKey(req), keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
}) })
export const webhookLimiter = withStore('rl:webhook:', { // Tight limiter for admin endpoints
windowMs: 60 * 1000, export const adminLimiter = rateLimit({
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:', {
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 100, max: 100,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => { keyGenerator: (req) => {
const ip = getClientIpKey(req) const ip = getClientIpKey(req)
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}` 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 }, 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, windowMs: 60 * 1000,
max: 240, max: 240,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => { keyGenerator: (req) => {
const ip = getClientIpKey(req) const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(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 jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth' import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
function responseStub() { function responseStub() {
const res = { status: vi.fn(), json: vi.fn() } const res = { status: vi.fn(), json: vi.fn() }
@@ -95,19 +95,18 @@ describe('requireAdminAuth middleware', () => {
expect(next).toHaveBeenCalledTimes(1) 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) 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({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next) await requireAdminAuth(req, res, next)
expect(req.admin).toEqual(admin) expect(res.status).toHaveBeenCalledWith(403)
expect(next).toHaveBeenCalledTimes(1) expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
expect(res.status).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
}) })
}) })
@@ -124,7 +123,7 @@ describe('requireAdminRole middleware', () => {
expect(next).not.toHaveBeenCalled() 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 req = { admin: { role: 'VIEWER' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
@@ -134,26 +133,13 @@ describe('requireAdminRole middleware', () => {
expect(res.status).toHaveBeenCalledWith(403) expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'forbidden', error: 'forbidden',
message: 'This action requires explicit FINANCE permission', message: 'This action requires the FINANCE role or higher',
statusCode: 403, statusCode: 403,
}) })
expect(next).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
}) })
it('allows admins at or above the required rank', () => {
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', () => {
const req = { admin: { role: 'ADMIN' } } as Request const req = { admin: { role: 'ADMIN' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
@@ -164,54 +150,3 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled() 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 { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies' import { getSessionCookieName } from '../security/sessionCookies'
const ADMIN_ROLE_ALLOWLIST: Record<AdminRole, readonly AdminRole[]> = { const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: ['SUPER_ADMIN'], SUPER_ADMIN: 5,
ADMIN: ['SUPER_ADMIN', 'ADMIN'], ADMIN: 4,
SUPPORT: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT'], SUPPORT: 3,
FINANCE: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'], FINANCE: 2,
VIEWER: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'], 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') 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.admin = admin
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
next() next()
@@ -50,10 +67,11 @@ export function requireAdminRole(minimumRole: AdminRole) {
const admin = req.admin const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required') 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)) { if (rank < required) {
return sendForbidden(res, 'forbidden', `This action requires explicit ${minimumRole} permission`) return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
} }
next() 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') return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
} }
if (!req.adminAuthLast2faAt) { const last2faAt = req.adminAuthLast2faAt
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session') if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
} return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
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')
} }
next() 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)
}
+25 -82
View File
@@ -1,52 +1,50 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma' 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' import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const COMPANY_READ_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] const 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 })
}
/** /**
* Read access: allows healthy read-only states, but blocks companies that should * Blocks requests for companies with lapsed or unactivated subscriptions.
* not have normal application visibility at all. * 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 { try {
const company = req.company const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (COMPANY_READ_BLOCKED_STATUSES.includes(company.status)) { if (BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription( return sendPaymentRequired(
res, res,
`subscription_${company.status.toLowerCase()}`, `subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED' company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.' ? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.', : '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)) { if (!hasAnyAccess(subscriptionStatus)) {
return blockSubscription(res, 'subscription_required', 'Your subscription has ended. Please reactivate to continue.', { return sendPaymentRequired(
res,
'subscription_required',
'Your subscription has ended. Please reactivate to continue.',
{
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`,
subscriptionStatus, subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus), accessLevel: getAccessLevel(subscriptionStatus),
}) },
)
} }
next() next()
@@ -54,58 +52,3 @@ export async function requireSubscriptionRead(req: Request, res: Response, next:
next(error) 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, billingAccountUpdateSchema,
billingCreditNoteSchema, billingCreditNoteSchema,
billingRefundSchema, billingRefundSchema,
collectionsOverrideSchema,
confirmManualPaymentSchema,
createBillingInvoiceSchema, createBillingInvoiceSchema,
payBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
} from './admin.schemas' } from './admin.schemas'
describe('admin billing schemas', () => { describe('admin billing schemas', () => {
@@ -47,38 +44,10 @@ describe('admin billing schemas', () => {
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow() 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', () => { 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: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow() expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow() expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).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 { prisma } from '../../lib/prisma'
import { NotFoundError, ValidationError } from '../../http/errors' import { NotFoundError, ValidationError } from '../../http/errors'
import { generateInvoicePdf } from '../../services/invoicePdfService' import { generateInvoicePdf } from '../../services/invoicePdfService'
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
const BLOCKING_INVOICE_TYPES = new Set([ const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL', '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']) const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
function toSequenceNumber(value: unknown) { function toSequenceNumber(value: unknown) {
if (typeof value === 'bigint') return Number(value) if (typeof value === 'bigint') return Number(value)
if (typeof value === 'number') return 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 } 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: { async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null billingAccountId?: string | null
invoiceId?: string | null invoiceId?: string | null
@@ -162,41 +123,11 @@ async function createAuditLog(data: {
} }
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) { async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
const existingAccounts = await tx.billingAccount.findMany({ const existing = await tx.billingAccount.findFirst({
where: { companyId, isPrimary: true }, where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
orderBy: { createdAt: 'desc' },
}) })
if (existingAccounts.length) { if (existing) return existing
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]
}
const company = await tx.company.findUniqueOrThrow({ const company = await tx.company.findUniqueOrThrow({
where: { id: companyId }, where: { id: companyId },
@@ -396,7 +327,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
} }
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) { function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
const where: any = { isPrimary: true } const where: any = {}
if (query.q) { if (query.q) {
where.OR = [ where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } }, { 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), .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 } return { data, total, stats }
} }
@@ -560,15 +504,7 @@ export async function getBillingAccountDetail(companyId: string) {
lineItems: { orderBy: { createdAt: 'asc' } }, lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } }, paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: '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, taxRecords: true,
subscriptionUpgradeRequest: { select: { id: true, status: true } },
creditNotes: { orderBy: { createdAt: 'desc' } }, creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { 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') if (!account) throw new NotFoundError('Billing account not found')
return withBillingAccountBalances(account) return account
} }
export async function updateBillingAccount( export async function updateBillingAccount(
@@ -633,25 +569,6 @@ export async function updateBillingAccount(
return updated 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( export async function setDunningPaused(
billingAccountId: string, billingAccountId: string,
paused: boolean, paused: boolean,
@@ -781,7 +698,6 @@ export async function createDraftInvoice(
} }
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) { export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const platformBillingSettings = await getPlatformBillingSettings()
const invoice = await prisma.$transaction(async (tx: any) => { const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({ const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId }, 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 taxBase = Math.max(preTaxBase - autoCreditToApply, 0)
const shouldApplyTax = current.invoiceType !== 'MANUAL' const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0
const { taxRate, taxAmount } = shouldApplyTax
? calculateTaxAmount(taxBase, account.taxExempt, platformBillingSettings.taxRate)
: { taxRate: 0, taxAmount: 0 }
if (taxAmount > 0) { if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({ await tx.billingInvoiceLineItem.create({
@@ -991,9 +905,6 @@ export async function payInvoice(
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Invoice is not payable in its current state') 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 const amount = data.amount ?? current.amountDue
if (amount <= 0 || amount > current.amountDue) { if (amount <= 0 || amount > current.amountDue) {
@@ -1472,7 +1383,6 @@ export async function getInvoicePdf(invoiceId: string) {
transactionId: latestPaymentAttempt?.providerPaymentId ?? null, transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(), paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({ lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description, description: item.description,
amount: item.amount, amount: item.amount,
currency: item.currency, currency: item.currency,
@@ -1485,7 +1395,6 @@ export async function getInvoicePdf(invoiceId: string) {
subtotalAmount: invoice.subtotalAmount, subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount, discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount, creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount, taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid, 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', role: 'SUPER_ADMIN',
passwordHash: 'hash', passwordHash: 'hash',
totpSecret: 'secret', 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' }) expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
}) })
it('wraps sessions without leaking credentials', () => { it('wraps sessions without leaking credentials', () => {
expect( expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
presentAdminSession(
{
id: 'admin_1',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
},
'jwt-token',
),
).toEqual({
token: 'jwt-token', token: 'jwt-token',
admin: { id: 'admin_1' }, 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) { export function presentAdminUser<T extends Record<string, any>>(admin: T) {
const safe = { ...admin } const { passwordHash, totpSecret, ...safe } = admin
for (const field of ADMIN_SECRET_FIELDS) {
delete (safe as Record<string, unknown>)[field]
}
return safe return safe
} }
@@ -11,7 +11,6 @@ vi.mock('../../lib/prisma', () => ({
})) }))
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import * as repo from './admin.repo' import * as repo from './admin.repo'
describe('admin.repo edge queries', () => { describe('admin.repo edge queries', () => {
@@ -63,7 +62,7 @@ describe('admin.repo edge queries', () => {
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({ expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: { where: {
passwordResetToken: hashPublicAccessToken('reset-token'), passwordResetToken: 'reset-token',
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') }, passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
}, },
}) })
+18 -140
View File
@@ -1,5 +1,4 @@
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
const companyListInclude = { const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } }, 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 } }) 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[]) { export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
@@ -85,18 +80,17 @@ export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } }) 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({ return prisma.adminUser.update({
where: { id }, where: { id },
data: { passwordResetToken: tokenHash, passwordResetExpiresAt: expiresAt }, data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
}) })
} }
export function findAdminByResetToken(token: string) { export function findAdminByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.adminUser.findFirst({ return prisma.adminUser.findFirst({
where: { where: {
passwordResetToken: tokenHash, passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() }, passwordResetExpiresAt: { gt: new Date() },
}, },
}) })
@@ -161,19 +155,12 @@ export async function applyCompanyUpdate(
name: string name: string
slug: string slug: string
address?: unknown address?: unknown
brand?: Record<string, unknown> | null brand?: { paymentMethodsEnabled?: any[] | null } | null
}, },
) { ) {
return prisma.$transaction(async (tx: any) => { return prisma.$transaction(async (tx: any) => {
if (body.company) { if (body.company) {
const companyData = { ...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)) { if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address) const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown> ? current.address as Record<string, unknown>
@@ -219,6 +206,7 @@ export async function applyCompanyUpdate(
companyId: id, companyId: id,
displayName: body.brand.displayName ?? current.name, displayName: body.brand.displayName ?? current.name,
subdomain: body.brand.subdomain ?? current.slug, subdomain: body.brand.subdomain ?? current.slug,
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
...body.brand, ...body.brand,
} as any, } as any,
}) })
@@ -355,7 +343,6 @@ export function createAdmin(data: {
firstName: string firstName: string
lastName: string lastName: string
role: string role: string
preferredLocale: string
passwordHash: string passwordHash: string
permissions?: any[] permissions?: any[]
}) { }) {
@@ -365,7 +352,6 @@ export function createAdmin(data: {
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, lastName: data.lastName,
role: data.role as any, role: data.role as any,
preferredLocale: data.preferredLocale,
passwordHash: data.passwordHash, passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined, permissions: data.permissions ? { create: data.permissions } : undefined,
}, },
@@ -384,7 +370,6 @@ export function updateAdmin(
firstName?: string firstName?: string
lastName?: string lastName?: string
role?: string role?: string
preferredLocale?: string
passwordHash?: string passwordHash?: string
isActive?: boolean isActive?: boolean
}, },
@@ -396,7 +381,6 @@ export function updateAdmin(
...(data.firstName !== undefined ? { firstName: data.firstName } : {}), ...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}), ...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}), ...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}), ...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}), ...(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({ return prisma.planFeature.create({
data: { data: {
plan: data.plan, 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({ return prisma.planFeature.update({
where: { id }, where: { id },
data, data,
@@ -578,126 +562,20 @@ export async function listNotificationsPage(query: {
page: number page: number
pageSize: number pageSize: number
}) { }) {
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']) const where: any = {}
const legacyWhere: any = {} if (query.channel) where.channel = query.channel
if (query.channel) legacyWhere.channel = query.channel if (query.status) where.status = query.status
if (query.status && legacyStatuses.has(query.status)) { if (query.companyId) where.companyId = query.companyId
legacyWhere.status = query.status
} else if (query.status) {
legacyWhere.id = '__delivery_status_only__'
}
if (query.companyId) legacyWhere.companyId = query.companyId
const deliveryWhere: any = {} const [data, total] = await Promise.all([
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([
prisma.notification.findMany({ prisma.notification.findMany({
where: legacyWhere, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take, skip: (query.page - 1) * query.pageSize,
include: { take: query.pageSize,
company: { select: { id: true, name: true } }, include: { company: { select: { name: true } } },
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
},
}), }),
prisma.notificationDelivery.findMany({ prisma.notification.count({ where }),
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 }),
]) ])
return { data, total }
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 }
} }
+4 -170
View File
@@ -1,18 +1,14 @@
import { Router } from 'express' 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 { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond' import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies' import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service' import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service' import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.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 { presentAdminUser } from './admin.presenter'
import { import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, email2faVerifySchema, loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema, companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema, invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema, createAdminSchema, adminRoleSchema, adminPermissionsSchema,
@@ -21,15 +17,9 @@ import {
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema, pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema, promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema, billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema, retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema, menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema, menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
upgradeRequestsQuerySchema, upgradeRequestIdParamSchema, upgradeCorrectionSchema, upgradeRejectSchema, approveUpgradePaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas' } from './admin.schemas'
import { z } from 'zod' 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 { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode) 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 (!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 }) 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) setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result) ok(res, result)
} catch (err) { next(err) } } catch (err) { next(err) }
@@ -90,28 +80,12 @@ router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, presentAdminUser(req.admin as any)) 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 { try {
ok(res, await service.setupTotp(req.admin.id, req.admin.email)) ok(res, await service.setupTotp(req.admin.id, req.admin.email))
} catch (err) { next(err) } } 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) => { router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try { try {
const { code } = parseBody(totpVerifySchema, req) const { code } = parseBody(totpVerifySchema, req)
@@ -301,17 +275,6 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
} catch (err) { next(err) } } 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 ──────────────────────────────────────────────── // ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { 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) } } 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) => { router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { try {
const { companyId } = parseParams(companyIdParamSchema, req) 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 { z } from 'zod'
import { contactPhoneField, countryCodeField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
import type { import type {
CarplaceHomepageContent, CarplaceHomepageContent,
CarplaceHomepageHowItWorksStep, CarplaceHomepageHowItWorksStep,
@@ -11,14 +10,14 @@ import type {
} from '@rentaldrivego/types' } from '@rentaldrivego/types'
export const loginSchema = z.object({ 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), password: z.string().max(128),
totpCode: z.string().length(6).optional(), totpCode: z.string().length(6).optional(),
recoveryCode: z.string().min(8).max(32).optional(), recoveryCode: z.string().min(8).max(32).optional(),
}) })
export const forgotPasswordSchema = z.object({ 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({ export const resetPasswordSchema = z.object({
@@ -30,10 +29,6 @@ export const totpVerifySchema = z.object({
code: z.string().length(6), code: z.string().length(6),
}) })
export const email2faVerifySchema = z.object({
code: z.string().length(6),
})
export const companiesQuerySchema = z.object({ export const companiesQuerySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
status: 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), 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({ export const notificationsQuerySchema = z.object({
channel: notificationChannelSchema.optional(), channel: z.string().optional(),
status: notificationStatusSchema.optional(), status: z.string().optional(),
companyId: z.string().optional(), companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50), 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({ export const createAdminSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase(), email: z.string().email().trim().toLowerCase(),
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')), firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')), lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']), role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
preferredLocale: languageSchema.default('en'),
password: z.string().min(8), password: z.string().min(8),
permissions: z.array(permissionSchema).optional(), 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({ export const updateAdminSchema = z.object({
email: z.string().email().max(254).trim().toLowerCase().optional(), email: z.string().email().trim().toLowerCase().optional(),
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(), firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(), lastName: z.string().min(1).max(100).trim().optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(), role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
preferredLocale: languageSchema.optional(),
password: z.string().min(8).optional(), password: z.string().min(8).optional(),
isActive: z.boolean().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({ export const adminRoleSchema = z.object({
@@ -136,26 +111,20 @@ export const companyStatusSchema = z.object({
}) })
const nullableString = z.union([z.string(), z.null()]).optional() 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 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() const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
export const adminCompanyUpdateSchema = z.object({ export const adminCompanyUpdateSchema = z.object({
company: z.object({ company: z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
slug: z email: z.string().email().optional(), phone: nullableString,
.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(),
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(), status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString, subscriptionPaymentRef: nullableString,
address: z.object({ address: z.object({
streetAddress: nullableString, streetAddress: nullableString,
city: z.union([z.string().trim().max(85), z.null()]).optional(), city: nullableString,
country: z.union([countryCodeField(), z.null()]).optional(), country: nullableString,
zipCode: nullableString, zipCode: nullableString,
legalName: nullableString, legalName: nullableString,
legalForm: nullableString, legalForm: nullableString,
@@ -173,12 +142,12 @@ export const adminCompanyUpdateSchema = z.object({
responsibleRole: nullableString, responsibleRole: nullableString,
responsibleIdentityNumber: nullableString, responsibleIdentityNumber: nullableString,
responsibleQualification: nullableString, responsibleQualification: nullableString,
responsiblePhone: z.union([contactPhoneField(), z.null()]).optional(), responsiblePhone: nullableString,
responsibleEmail: nullableEmail, responsibleEmail: nullableEmail,
}).optional(), }).optional(),
}).optional(), }).optional(),
subscription: z.object({ subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']).optional(), plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(), status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.literal('MAD').optional(), currency: z.literal('MAD').optional(),
@@ -189,9 +158,9 @@ export const adminCompanyUpdateSchema = z.object({
brand: z.object({ brand: z.object({
displayName: z.string().min(1).optional(), tagline: nullableString, displayName: z.string().min(1).optional(), tagline: nullableString,
subdomain: z.string().min(1).optional(), customDomain: 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(), publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
publicCity: z.union([z.string().trim().max(85), z.null()]).optional(), publicCountry: z.union([countryCodeField(), z.null()]).optional(), websiteUrl: nullableUrl, publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: z.union([contactPhoneField(), z.null()]).optional(), defaultLocale: languageSchema.optional(), whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.literal('MAD').optional(), defaultCurrency: z.literal('MAD').optional(),
isListedOnCarplace: z.boolean().optional(), isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.any().optional(), homePageConfig: z.any().optional(),
@@ -201,7 +170,7 @@ export const adminCompanyUpdateSchema = z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString, legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(), terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).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(), signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(), }).optional(),
accountingSettings: z.object({ accountingSettings: z.object({
@@ -263,7 +232,7 @@ export const invoiceIdParamSchema = z.object({
export const billingAccountUpdateSchema = z.object({ export const billingAccountUpdateSchema = z.object({
legalName: z.string().min(1).max(255).optional(), 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(), billingAddress: z.any().optional(),
taxId: z.union([z.string().max(120), z.null()]).optional(), taxId: z.union([z.string().max(120), z.null()]).optional(),
taxExempt: z.boolean().optional(), taxExempt: z.boolean().optional(),
@@ -271,10 +240,6 @@ export const billingAccountUpdateSchema = z.object({
netTermsDays: z.number().int().min(0).max(365).optional(), 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({ export const billingLineItemInputSchema = z.object({
type: z.enum([ type: z.enum([
'SUBSCRIPTION_FEE', 'SUBSCRIPTION_FEE',
@@ -324,119 +289,6 @@ export const payBillingInvoiceSchema = z.object({
providerPaymentId: z.union([z.string(), z.null()]).optional(), 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({ export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(), paymentMethodId: z.union([z.string(), z.null()]).optional(),
}) })
@@ -461,14 +313,14 @@ export const homepageUpdateSchema = z.object({
export const pricingUpdateSchema = z.object({ export const pricingUpdateSchema = z.object({
entries: z.array(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']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(), amount: z.number().int().positive(),
})).min(1), })).min(1),
}) })
const planFeatureSchema = z.object({ const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']), plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
label: z.string().min(1).max(120), label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0), sortOrder: z.number().int().min(0).default(0),
}) })
@@ -481,7 +333,7 @@ export const promotionCreateSchema = z.object({
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']), discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(), 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'])), periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(), maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(), 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) }) export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT']) 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']) const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({ export const menuItemSchema = z.object({
@@ -1,61 +1,28 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({ vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(), findAdminByEmail: vi.fn(),
findAdminByIdOrThrow: vi.fn(),
setAdminPasswordReset: vi.fn(), setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
updateAdminTotpSecret: vi.fn(),
createAuditLog: vi.fn(),
})) }))
vi.mock('../../services/notificationService', () => ({ vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), 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 * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService' import { sendTransactionalEmail } from '../../services/notificationService'
import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { forgotPassword } from './admin.service'
import { forgotPassword, login, setupTotp } from './admin.service'
describe('admin.service forgotPassword', () => { describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL const originalAdminUrl = process.env.ADMIN_URL
const originalJwtSecret = process.env.JWT_SECRET
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
redisStore.clear()
process.env.ADMIN_URL = 'http://localhost:3000/admin' process.env.ADMIN_URL = 'http://localhost:3000/admin'
process.env.JWT_SECRET = 'test-jwt-secret'
}) })
afterAll(() => { afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl process.env.ADMIN_URL = originalAdminUrl
process.env.JWT_SECRET = originalJwtSecret
}) })
it('sends the reset email to the canonical stored admin address', async () => { 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()
})
}) })
+12 -116
View File
@@ -1,22 +1,16 @@
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import crypto from 'crypto' import crypto from 'crypto'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { authenticator } from 'otplib' import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens' import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode' import qrcode from 'qrcode'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService' import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService' import { sendTransactionalEmail } from '../../services/notificationService'
import { redis } from '../../lib/redis'
import * as presenter from './admin.presenter' import * as presenter from './admin.presenter'
import * as repo from './admin.repo' import * as repo from './admin.repo'
import * as billingService from './admin.billing.service' import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60 const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10 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() { function generateRecoveryCode() {
@@ -62,61 +56,6 @@ function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt }) 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) { function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value)) 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) const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return null if (!valid) return null
let last2faAt: number | undefined if (admin.totpEnabled) {
if (admin.totpEnabled && (totpCode || recoveryCode)) { if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode && admin.totpSecret
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! }) ? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false : false
const validEmailOtp = !validTotp && !admin.totpSecret && await consumeAdminEmailOtp(admin.id, totpCode) const validRecoveryCode = !validTotp && recoveryCode
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode) ? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false : false
if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const if (!validTotp && !validRecoveryCode) {
last2faAt = Date.now() return { invalidTotp: true } as const
}
} }
await repo.updateAdminLastLogin(admin.id) await repo.updateAdminLastLogin(admin.id)
@@ -162,47 +102,17 @@ export async function login(email: string, password: string, totpCode?: string,
resourceId: admin.id, 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) { export async function setupTotp(adminId: string, email: string) {
const admin = await repo.findAdminByIdOrThrow(adminId) const secret = authenticator.generateSecret()
const secret = admin.totpSecret && !admin.totpEnabled
? admin.totpSecret
: authenticator.generateSecret()
if (secret !== admin.totpSecret) {
await repo.updateAdminTotpSecret(adminId, secret) await repo.updateAdminTotpSecret(adminId, secret)
}
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret) const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth) const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode } 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) { export async function verifyTotp(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId) const admin = await repo.findAdminByIdOrThrow(adminId)
if (!admin.totpSecret) return false if (!admin.totpSecret) return false
@@ -234,7 +144,7 @@ export async function forgotPassword(email: string) {
const rawToken = crypto.randomBytes(32).toString('hex') const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000) 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( const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin', 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)) 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({ const admin = await repo.createAdmin({
...body, ...body,
passwordHash: await bcrypt.hash(body.password, 12), passwordHash: await bcrypt.hash(body.password, 12),
@@ -375,7 +285,6 @@ export async function updateAdmin(
firstName?: string firstName?: string
lastName?: string lastName?: string
role?: string role?: string
preferredLocale?: string
password?: string password?: string
isActive?: boolean isActive?: boolean
}, },
@@ -385,7 +294,6 @@ export async function updateAdmin(
...(body.firstName !== undefined ? { firstName: body.firstName } : {}), ...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}), ...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}), ...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}), ...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}), ...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
}) })
@@ -433,18 +341,6 @@ export function updateBillingAccount(
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip) 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) { export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip) return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
} }
@@ -1,7 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant' import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead } from '../../middleware/requireSubscription' import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole' import { requireRole } from '../../middleware/requireRole'
import { parseQuery } from '../../http/validate' import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond' import { ok } from '../../http/respond'
@@ -10,22 +10,22 @@ import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
const router = Router() 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 { try {
const { period } = parseQuery(summaryQuerySchema, req) const { period } = parseQuery(summaryQuerySchema, req)
ok(res, await service.getSummary(req.companyId, period)) ok(res, await service.getSummary(req.companyId, period))
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
router.get('/dashboard', requireRole('MANAGER'), async (req, res, next) => { router.get('/dashboard', async (req, res, next) => {
try { try {
ok(res, await service.getDashboard(req.companyId)) ok(res, await service.getDashboard(req.companyId))
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
router.get('/sources', requireRole('MANAGER'), async (req, res, next) => { router.get('/sources', async (req, res, next) => {
try { try {
ok(res, await service.getSources(req.companyId)) ok(res, await service.getSources(req.companyId))
} catch (err) { next(err) } } catch (err) { next(err) }
@@ -1,12 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
export const summaryQuerySchema = z.object({ export const summaryQuerySchema = z.object({
period: z.string().trim().max(20).default('30d'), period: z.string().default('30d'),
}) })
export const reportQuerySchema = z.object({ export const reportQuerySchema = z.object({
from: z.string().trim().max(30).optional(), from: z.string().optional(),
to: z.string().trim().max(30).optional(), to: z.string().optional(),
format: z.enum(['JSON', 'CSV']).default('JSON'), format: z.string().default('JSON'),
period: z.string().trim().max(20).optional(), period: z.string().optional(),
}) })
@@ -1,25 +1,13 @@
import { z } from 'zod' import { z } from 'zod'
import { localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
/** /**
* Minimal signup schema — only asks for what's needed to create an identity. * Minimal signup schema — only asks for what's needed to create an identity.
* See progressive-signup-plan.md Section 3. * See progressive-signup-plan.md Section 3.
*/ */
export const accountStartSchema = z.object({ export const accountStartSchema = z.object({
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')), email: z.string().email(),
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()),
password: z.string().min(8).max(128), password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), 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({ export const companyProfileSchema = z.object({
@@ -54,6 +42,7 @@ export const legalIdentitySchema = z.object({
}) })
export const paymentSetupSchema = z.object({ export const paymentSetupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
responsibleName: z.string().min(1).max(160), responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120), responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: 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({ export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']), plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
}) })
@@ -2,8 +2,7 @@ import bcrypt from 'bcryptjs'
import crypto from 'crypto' import crypto from 'crypto'
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { sendTransactionalEmail } from '../../services/notificationService'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo' import * as repo from './auth.company.repo'
import type { output } from 'zod' import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas' import type { accountStartSchema } from './auth.account.schemas'
@@ -42,7 +41,7 @@ export async function startAccount(body: AccountStartInput) {
const company = await tx.company.create({ const company = await tx.company.create({
data: { data: {
name: body.companyName, name: '',
slug, slug,
email: body.email, email: body.email,
address: {}, address: {},
@@ -53,7 +52,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.brandSettings.create({ await tx.brandSettings.create({
data: { data: {
companyId: company.id, companyId: company.id,
displayName: body.companyName, displayName: body.email.split('@')[0] || 'My Workspace',
subdomain: slug, subdomain: slug,
defaultLocale: body.preferredLanguage, defaultLocale: body.preferredLanguage,
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
@@ -63,7 +62,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.subscription.create({ await tx.subscription.create({
data: { data: {
companyId: company.id, companyId: company.id,
plan: body.subscriptionPlan ?? 'STARTER', plan: 'STARTER',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
status: 'TRIALING', status: 'TRIALING',
@@ -78,11 +77,11 @@ export async function startAccount(body: AccountStartInput) {
data: { data: {
companyId: company.id, companyId: company.id,
clerkUserId: `local_owner_${company.id}`, clerkUserId: `local_owner_${company.id}`,
firstName: body.firstName, firstName: '',
lastName: body.lastName, lastName: '',
email: body.email, email: body.email,
passwordHash, passwordHash,
emailVerificationToken: hashPublicAccessToken(verificationToken), emailVerificationToken: verificationToken,
role: 'OWNER', role: 'OWNER',
preferredLanguage: body.preferredLanguage, preferredLanguage: body.preferredLanguage,
isActive: true, isActive: true,
@@ -126,7 +125,8 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang], text: emailTexts[lang],
}).catch((err) => { }).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(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 { return {
@@ -32,7 +32,7 @@ type CompanySignupInput = {
responsibleEmail: string responsibleEmail: string
currency: 'MAD' currency: 'MAD'
registrationNumber: string registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE' plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL' billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar' preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string firstName: string
@@ -1,10 +1,9 @@
import { z } from 'zod' import { z } from 'zod'
import { contactPhoneField, countryCodeField, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
export const companySignupSchema = z.object({ export const companySignupSchema = z.object({
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')), firstName: z.string().min(1).max(80),
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')), lastName: z.string().min(1).max(80),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()), email: z.string().email(),
password: z.string().min(8).max(128), password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120), companyName: z.string().min(2).max(120),
legalName: z.string().min(2).max(160), legalName: z.string().min(2).max(160),
@@ -16,10 +15,10 @@ export const companySignupSchema = z.object({
operatingLicenseIssuedAt: z.string().min(1).max(40), operatingLicenseIssuedAt: z.string().min(1).max(40),
operatingLicenseIssuedBy: z.string().min(1).max(160), operatingLicenseIssuedBy: z.string().min(1).max(160),
streetAddress: z.string().min(1).max(200), streetAddress: z.string().min(1).max(200),
city: z.string().trim().min(1).max(85).transform((value) => value.normalize('NFC')), city: z.string().min(1).max(120),
country: countryCodeField(), country: z.string().min(1).max(120),
zipCode: z.string().min(1).max(40), zipCode: z.string().min(1).max(40),
companyPhone: contactPhoneField(), companyPhone: z.string().min(1).max(80),
companyEmail: z.string().email(), companyEmail: z.string().email(),
fax: z.string().max(80).optional(), fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80), yearsActive: z.string().min(1).max(80),
@@ -29,20 +28,11 @@ export const companySignupSchema = z.object({
responsibleRole: z.string().min(1).max(120), responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120), responsibleIdentityNumber: z.string().min(1).max(120),
responsibleQualification: z.string().max(200).optional(), responsibleQualification: z.string().max(200).optional(),
responsiblePhone: contactPhoneField(), responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(), responsibleEmail: z.string().email(),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), 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']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'), currency: z.literal('MAD'),
}).superRefine((signup, ctx) => { paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
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') })
}
}) })
@@ -45,6 +45,7 @@ const body = {
plan: 'PRO' as const, plan: 'PRO' as const,
billingPeriod: 'MONTHLY' as const, billingPeriod: 'MONTHLY' as const,
currency: 'MAD' as const, currency: 'MAD' as const,
paymentProvider: 'PAYPAL' as const,
} }
describe('auth.company.service', () => { describe('auth.company.service', () => {
@@ -104,6 +105,7 @@ describe('auth.company.service', () => {
templateVariables: expect.objectContaining({ templateVariables: expect.objectContaining({
firstName: 'Aya', firstName: 'Aya',
companyName: 'Atlas & Desert Cars!!!', companyName: 'Atlas & Desert Cars!!!',
paymentProvider: 'PAYPAL',
}), }),
})) }))
}) })
@@ -100,6 +100,7 @@ export async function signup(body: CompanySignupInput) {
planName: localizePlanName(body.plan, lang), planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang), billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency, currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt, trialEndDate: trialEndAt,
}, },
}).catch(() => []) }).catch(() => [])
@@ -4,14 +4,12 @@ const prismaMock = vi.hoisted(() => ({
employee: { employee: {
findUnique: vi.fn(), findUnique: vi.fn(),
findFirst: vi.fn(), findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(), update: vi.fn(),
}, },
})) }))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock })) vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import * as repo from './auth.employee.repo' import * as repo from './auth.employee.repo'
describe('auth.employee.repo query boundaries', () => { 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 () => { it('looks up employee login emails case-insensitively and includes company context', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST') await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({ expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } }, where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
include: { company: true }, include: { company: true },
take: 2,
}) })
}) })
it('only sends forgot-password emails to active employees', async () => { it('only sends forgot-password emails to active employees', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findActiveEmployeeByEmail('agent@example.test') await repo.findActiveEmployeeByEmail('agent@example.test')
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({ expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: { where: {
email: { equals: 'agent@example.test', mode: 'insensitive' }, email: { equals: 'agent@example.test', mode: 'insensitive' },
isActive: true, 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.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z')) 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({ expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: { where: {
passwordResetToken: hashPublicAccessToken('reset_123'), passwordResetToken: 'reset_123',
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') }, 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', () => ({ vi.mock('../../lib/prisma', () => ({
prisma: { prisma: {
employee: { employee: {
findMany: vi.fn(), findFirst: vi.fn(),
}, },
}, },
})) }))
@@ -14,13 +14,12 @@ import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './aut
describe('auth.employee.repo', () => { describe('auth.employee.repo', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
}) })
it('looks up employee login email case-insensitively', async () => { it('looks up employee login email case-insensitively', async () => {
await findEmployeeWithCompanyByEmail('Owner@Example.com') await findEmployeeWithCompanyByEmail('Owner@Example.com')
expect(prisma.employee.findMany).toHaveBeenCalledWith({ expect(prisma.employee.findFirst).toHaveBeenCalledWith({
where: { where: {
email: { email: {
equals: 'Owner@Example.com', equals: 'Owner@Example.com',
@@ -28,14 +27,13 @@ describe('auth.employee.repo', () => {
}, },
}, },
include: { company: true }, include: { company: true },
take: 2,
}) })
}) })
it('looks up active employee reset email case-insensitively', async () => { it('looks up active employee reset email case-insensitively', async () => {
await findActiveEmployeeByEmail('Owner@Example.com') await findActiveEmployeeByEmail('Owner@Example.com')
expect(prisma.employee.findMany).toHaveBeenCalledWith({ expect(prisma.employee.findFirst).toHaveBeenCalledWith({
where: { where: {
email: { email: {
equals: 'Owner@Example.com', equals: 'Owner@Example.com',
@@ -43,15 +41,6 @@ describe('auth.employee.repo', () => {
}, },
isActive: true, 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 { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
export function findEmployeeWithCompanyById(id: string) { export function findEmployeeWithCompanyById(id: string) {
return prisma.employee.findUnique({ return prisma.employee.findUnique({
@@ -14,8 +13,8 @@ export function findEmployeeById(id: string) {
}) })
} }
export async function findEmployeeWithCompanyByEmail(email: string) { export function findEmployeeWithCompanyByEmail(email: string) {
const matches = await prisma.employee.findMany({ return prisma.employee.findFirst({
where: { where: {
email: { email: {
equals: email, equals: email,
@@ -23,21 +22,11 @@ export async function findEmployeeWithCompanyByEmail(email: string) {
}, },
}, },
include: { company: true }, 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 function findActiveEmployeeByEmail(email: string) {
} return prisma.employee.findFirst({
export async function findActiveEmployeeByEmail(email: string) {
const matches = await prisma.employee.findMany({
where: { where: {
email: { email: {
equals: email, equals: email,
@@ -45,17 +34,7 @@ export async function findActiveEmployeeByEmail(email: string) {
}, },
isActive: true, 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) { 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) { export function findEmployeeByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({ return prisma.employee.findFirst({
where: { where: {
passwordResetToken: tokenHash, passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() }, 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({ return prisma.employee.update({
where: { id }, where: { id },
data: { emailVerificationToken: tokenHash }, data: { emailVerificationToken: token },
}) })
} }
export function findEmployeeByVerificationToken(token: string) { export function findEmployeeByVerificationToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({ return prisma.employee.findFirst({
where: { emailVerificationToken: tokenHash }, where: { emailVerificationToken: token },
include: { company: true }, include: { company: true },
}) })
} }
@@ -6,7 +6,6 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
import { getEmployeeMenu } from '../menu/menu.service' import { getEmployeeMenu } from '../menu/menu.service'
import { import {
employeeForgotPasswordSchema, employeeForgotPasswordSchema,
employee2faVerifySchema,
employeeLanguageSchema, employeeLanguageSchema,
employeeLoginSchema, employeeLoginSchema,
employeeResetPasswordSchema, employeeResetPasswordSchema,
@@ -31,13 +30,7 @@ router.post('/login', async (req, res, next) => {
try { try {
const body = parseBody(employeeLoginSchema, req) const body = parseBody(employeeLoginSchema, req)
const result = await service.login(body) const result = await service.login(body)
if ('twoFactorRequired' in result) { if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
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)
}
ok(res, result) ok(res, result)
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
@@ -47,38 +40,6 @@ router.post('/logout', (_req, res) => {
ok(res, { success: true }) 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) => { router.post('/forgot-password', async (req, res, next) => {
try { try {
const { email } = parseBody(employeeForgotPasswordSchema, req) const { email } = parseBody(employeeForgotPasswordSchema, req)
@@ -1,13 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
export const employeeLoginSchema = z.object({ 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), password: z.string().max(128),
totpCode: z.string().length(6).optional(),
}) })
export const employeeForgotPasswordSchema = z.object({ 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({ export const employeeLanguageSchema = z.object({
@@ -18,7 +17,3 @@ export const employeeResetPasswordSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
password: z.string().min(8).max(128), 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