create-mercato-app 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,13 +68,35 @@ Complete reference of all available UI components in `@open-mercato/ui`.
68
68
 
69
69
  ### Input Components (`@open-mercato/ui/backend/inputs/*`)
70
70
 
71
+ Specialized inputs that ship with the framework. For each, the full props table, MUST rules, and anti-patterns live in `.ai/ui-components.md` under the section linked from the component name.
72
+
73
+ | Component | Import Path | Purpose | Key Props |
74
+ |-----------|-------------|---------|-----------|
75
+ | **ComboboxInput** | `@open-mercato/ui/backend/inputs/ComboboxInput` | Single-value typeahead with sync/async suggestions; allows free-form custom values by default | `value`, `onChange`, `suggestions`, `loadSuggestions`, `resolveLabel`, `allowCustomValues` |
76
+ | **TagsInput** | `@open-mercato/ui/backend/inputs/TagsInput` | Multi-value (`string[]`) version of `ComboboxInput` with rich `{ value, label, description }` triples | `value`, `onChange`, `suggestions`, `loadSuggestions`, `selectedOptions`, `resolveLabel` |
77
+ | **LookupSelect** | `@open-mercato/ui/backend/inputs/LookupSelect` | Rich card-list search/select with title/subtitle/icon/badge per row; returns selected id | `value`, `onChange`, `fetchItems`, `options`, `minQuery`, `actionSlot` |
78
+ | **PhoneNumberField** | `@open-mercato/ui/backend/inputs/PhoneNumberField` | Compound country-picker + national-number input matching Figma `Text Input [1.1]` Phone variant. Validates + normalizes on blur, optional duplicate lookup | `value`, `onValueChange`, `onDigitsChange`, `countries`, `defaultCountryIso2`, `onDuplicateLookup` |
79
+ | **EventSelect** | `@open-mercato/ui/backend/inputs/EventSelect` | Strict select for declared platform events, grouped by module. **Mandated by `packages/ui/AGENTS.md`** for event selection | `value`, `onChange`, `categories`, `modules`, `excludeTriggerExcluded`, `size` |
80
+ | **EventPatternInput** | `@open-mercato/ui/backend/inputs/EventPatternInput` | `ComboboxInput` preloaded with declared events that allows wildcard patterns (e.g. `sales.orders.*`) | `value`, `onChange`, `categories`, `modules` |
81
+ | **TimeInput** | `@open-mercato/ui/backend/inputs/TimeInput` | Bare `HH:MM` editor (two `<input type="number">` cells, no popover). Low-level atom — most flows want `TimePicker` | `value`, `onChange`, `minuteStep`, `hourLabel`, `minuteLabel` |
82
+ | **DatePicker** *(@deprecated shim — import from `primitives/date-picker`)* | `@open-mercato/ui/backend/inputs/DatePicker` | Backward-compat re-export of the `DatePicker` primitive. New code MUST import from `@open-mercato/ui/primitives/date-picker`. | — |
83
+ | **DateTimePicker** *(@deprecated shim — use `DatePicker withTime`)* | `@open-mercato/ui/backend/inputs/DateTimePicker` | Thin wrapper that always sets `withTime` on the primitive. New code: `<DatePicker withTime />` | — |
84
+ | **TimePicker** *(@deprecated shim — import from `primitives/time-picker`)* | `@open-mercato/ui/backend/inputs/TimePicker` | Legacy popover-anchored time picker wrapping the new `TimePicker` primitive. New code: `@open-mercato/ui/primitives/time-picker`. | — |
85
+ | **SwitchableMarkdownInput** *(@deprecated — use `RichEditor`)* | `@open-mercato/ui/backend/inputs/SwitchableMarkdownInput` | Markdown ⇄ plain textarea toggle. Kept only for backward compatibility with Markdown-backed surfaces. New rich-text fields MUST use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` (sanitized HTML). | `value`, `onChange`, `isMarkdownEnabled` |
86
+
87
+ ### Input Variants (Figma `Text Input [1.1]` — `@open-mercato/ui/primitives/*`)
88
+
89
+ Foundation-level input variants matching Figma type-specific designs. Use these instead of raw `<Input type="email">`, `<Input type="password">`, etc. — they ship correct visual chrome (leading icons, prefix boxes, trailing buttons, brand badges) and i18n-resolved labels.
90
+
71
91
  | Component | Import Path | Purpose | Key Props |
72
92
  |-----------|-------------|---------|-----------|
73
- | **TagsInput** | `@open-mercato/ui/backend/inputs/TagsInput` | Multi-tag input with suggestions | `value`, `onChange`, `placeholder`, `suggestions` |
74
- | **ComboboxInput** | `@open-mercato/ui/backend/inputs/ComboboxInput` | Searchable dropdown (single value) | `value`, `onChange`, `options`, `placeholder` |
75
- | **PhoneNumberField** | `@open-mercato/ui/backend/inputs/PhoneNumberField` | Phone input with formatting | `value`, `onChange`, `checkDuplicate` |
76
- | **LookupSelect** | `@open-mercato/ui/backend/inputs/LookupSelect` | Async lookup/search select | `value`, `onChange`, `onSearch`, `renderItem` |
77
- | **SwitchableMarkdownInput** | `@open-mercato/ui/backend/inputs/SwitchableMarkdownInput` | Text/markdown toggle input | `value`, `onChange`, `placeholder` |
93
+ | **EmailInput** | `@open-mercato/ui/primitives/email-input` | `Input` wrapper with leading Mail icon, `type="email"`, `autoComplete="email"` | `value`, `onChange`, `showIcon` |
94
+ | **SearchInput** | `@open-mercato/ui/primitives/search-input` | Leading Search icon + trailing × clear button (when value non-empty). Use for DataTable global filter, command palette, list-view live filter | `value`, `onChange`, `onClear`, `clearable` |
95
+ | **PasswordInput** | `@open-mercato/ui/primitives/password-input` | Leading Lock icon + trailing Eye/EyeOff reveal toggle. Controlled or uncontrolled reveal state | `value`, `onChange`, `revealable`, `revealed`, `onRevealedChange`, `showLockIcon` |
96
+ | **WebsiteInput** | `@open-mercato/ui/primitives/website-input` | Left "https://" prefix box + divider + URL input. `type="url"` | `value`, `onChange`, `prefix`, `showPrefix` |
97
+ | **AmountInput** | `@open-mercato/ui/primitives/amount-input` | Leading currency symbol + numeric input + trailing ISO currency picker (10 markets default) | `value: { amount, currency }`, `onChange`, `currencies`, `showCurrency` |
98
+ | **ButtonInput** | `@open-mercato/ui/primitives/button-input` | Input with optional `leftIcon` + divider + required `trailingAction` slot for interactive button (copy URL, send, regenerate) | `leftIcon`, `trailingAction` (required) |
99
+ | **CardInput** | `@open-mercato/ui/primitives/card-input` | Credit-card-number input with regex-based brand auto-detection (Visa/MC/Amex/Discover/Diners/JCB/UnionPay), format masking, brand badge | `value` (digits-only), `onChange`, `onBrandChange`, `brands` |
78
100
 
79
101
  ### Detail Page Components (`@open-mercato/ui/backend/detail/*`)
80
102
 
@@ -68,13 +68,35 @@ Complete reference of all available UI components in `@open-mercato/ui`.
68
68
 
69
69
  ### Input Components (`@open-mercato/ui/backend/inputs/*`)
70
70
 
71
+ Specialized inputs that ship with the framework. For each, the full props table, MUST rules, and anti-patterns live in `.ai/ui-components.md` under the section linked from the component name.
72
+
73
+ | Component | Import Path | Purpose | Key Props |
74
+ |-----------|-------------|---------|-----------|
75
+ | **ComboboxInput** | `@open-mercato/ui/backend/inputs/ComboboxInput` | Single-value typeahead with sync/async suggestions; allows free-form custom values by default | `value`, `onChange`, `suggestions`, `loadSuggestions`, `resolveLabel`, `allowCustomValues` |
76
+ | **TagsInput** | `@open-mercato/ui/backend/inputs/TagsInput` | Multi-value (`string[]`) version of `ComboboxInput` with rich `{ value, label, description }` triples | `value`, `onChange`, `suggestions`, `loadSuggestions`, `selectedOptions`, `resolveLabel` |
77
+ | **LookupSelect** | `@open-mercato/ui/backend/inputs/LookupSelect` | Rich card-list search/select with title/subtitle/icon/badge per row; returns selected id | `value`, `onChange`, `fetchItems`, `options`, `minQuery`, `actionSlot` |
78
+ | **PhoneNumberField** | `@open-mercato/ui/backend/inputs/PhoneNumberField` | Compound country-picker + national-number input matching Figma `Text Input [1.1]` Phone variant. Validates + normalizes on blur, optional duplicate lookup | `value`, `onValueChange`, `onDigitsChange`, `countries`, `defaultCountryIso2`, `onDuplicateLookup` |
79
+ | **EventSelect** | `@open-mercato/ui/backend/inputs/EventSelect` | Strict select for declared platform events, grouped by module. **Mandated by `packages/ui/AGENTS.md`** for event selection | `value`, `onChange`, `categories`, `modules`, `excludeTriggerExcluded`, `size` |
80
+ | **EventPatternInput** | `@open-mercato/ui/backend/inputs/EventPatternInput` | `ComboboxInput` preloaded with declared events that allows wildcard patterns (e.g. `sales.orders.*`) | `value`, `onChange`, `categories`, `modules` |
81
+ | **TimeInput** | `@open-mercato/ui/backend/inputs/TimeInput` | Bare `HH:MM` editor (two `<input type="number">` cells, no popover). Low-level atom — most flows want `TimePicker` | `value`, `onChange`, `minuteStep`, `hourLabel`, `minuteLabel` |
82
+ | **DatePicker** *(@deprecated shim — import from `primitives/date-picker`)* | `@open-mercato/ui/backend/inputs/DatePicker` | Backward-compat re-export of the `DatePicker` primitive. New code MUST import from `@open-mercato/ui/primitives/date-picker`. | — |
83
+ | **DateTimePicker** *(@deprecated shim — use `DatePicker withTime`)* | `@open-mercato/ui/backend/inputs/DateTimePicker` | Thin wrapper that always sets `withTime` on the primitive. New code: `<DatePicker withTime />` | — |
84
+ | **TimePicker** *(@deprecated shim — import from `primitives/time-picker`)* | `@open-mercato/ui/backend/inputs/TimePicker` | Legacy popover-anchored time picker wrapping the new `TimePicker` primitive. New code: `@open-mercato/ui/primitives/time-picker`. | — |
85
+ | **SwitchableMarkdownInput** *(@deprecated — use `RichEditor`)* | `@open-mercato/ui/backend/inputs/SwitchableMarkdownInput` | Markdown ⇄ plain textarea toggle. Kept only for backward compatibility with Markdown-backed surfaces. New rich-text fields MUST use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` (sanitized HTML). | `value`, `onChange`, `isMarkdownEnabled` |
86
+
87
+ ### Input Variants (Figma `Text Input [1.1]` — `@open-mercato/ui/primitives/*`)
88
+
89
+ Foundation-level input variants matching Figma type-specific designs. Use these instead of raw `<Input type="email">`, `<Input type="password">`, etc. — they ship correct visual chrome (leading icons, prefix boxes, trailing buttons, brand badges) and i18n-resolved labels.
90
+
71
91
  | Component | Import Path | Purpose | Key Props |
72
92
  |-----------|-------------|---------|-----------|
73
- | **TagsInput** | `@open-mercato/ui/backend/inputs/TagsInput` | Multi-tag input with suggestions | `value`, `onChange`, `placeholder`, `suggestions` |
74
- | **ComboboxInput** | `@open-mercato/ui/backend/inputs/ComboboxInput` | Searchable dropdown (single value) | `value`, `onChange`, `options`, `placeholder` |
75
- | **PhoneNumberField** | `@open-mercato/ui/backend/inputs/PhoneNumberField` | Phone input with formatting | `value`, `onChange`, `checkDuplicate` |
76
- | **LookupSelect** | `@open-mercato/ui/backend/inputs/LookupSelect` | Async lookup/search select | `value`, `onChange`, `onSearch`, `renderItem` |
77
- | **SwitchableMarkdownInput** | `@open-mercato/ui/backend/inputs/SwitchableMarkdownInput` | Text/markdown toggle input | `value`, `onChange`, `placeholder` |
93
+ | **EmailInput** | `@open-mercato/ui/primitives/email-input` | `Input` wrapper with leading Mail icon, `type="email"`, `autoComplete="email"` | `value`, `onChange`, `showIcon` |
94
+ | **SearchInput** | `@open-mercato/ui/primitives/search-input` | Leading Search icon + trailing × clear button (when value non-empty). Use for DataTable global filter, command palette, list-view live filter | `value`, `onChange`, `onClear`, `clearable` |
95
+ | **PasswordInput** | `@open-mercato/ui/primitives/password-input` | Leading Lock icon + trailing Eye/EyeOff reveal toggle. Controlled or uncontrolled reveal state | `value`, `onChange`, `revealable`, `revealed`, `onRevealedChange`, `showLockIcon` |
96
+ | **WebsiteInput** | `@open-mercato/ui/primitives/website-input` | Left "https://" prefix box + divider + URL input. `type="url"` | `value`, `onChange`, `prefix`, `showPrefix` |
97
+ | **AmountInput** | `@open-mercato/ui/primitives/amount-input` | Leading currency symbol + numeric input + trailing ISO currency picker (10 markets default) | `value: { amount, currency }`, `onChange`, `currencies`, `showCurrency` |
98
+ | **ButtonInput** | `@open-mercato/ui/primitives/button-input` | Input with optional `leftIcon` + divider + required `trailingAction` slot for interactive button (copy URL, send, regenerate) | `leftIcon`, `trailingAction` (required) |
99
+ | **CardInput** | `@open-mercato/ui/primitives/card-input` | Credit-card-number input with regex-based brand auto-detection (Visa/MC/Amex/Discover/Diners/JCB/UnionPay), format masking, brand badge | `value` (digits-only), `onChange`, `onBrandChange`, `brands` |
78
100
 
79
101
  ### Detail Page Components (`@open-mercato/ui/backend/detail/*`)
80
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.6.1-develop.3276.1.1fcd145078",
3
+ "version": "0.6.1-develop.3287.1.450f4ffb56",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -480,3 +480,64 @@ OCR_MODEL=gpt-4o
480
480
 
481
481
  # Index prefix for tenant isolation (default: om)
482
482
  # MEILISEARCH_INDEX_PREFIX=om
483
+
484
+ # ============================================================================
485
+ # Customer Portal Custom Domain (Phase 1–3)
486
+ # ============================================================================
487
+ # See .ai/specs/2026-04-08-portal-custom-domain-routing.md and
488
+ # docker/traefik/README.md. Required when custom-domain mappings are in use.
489
+
490
+ # Primary platform host — excluded from the Traefik domain-check middleware
491
+ # and treated as the platform pass-through host by the Node middleware.
492
+ PLATFORM_PRIMARY_HOST=openmercato.local
493
+
494
+ # Mandatory shared secret for /api/customer_accounts/domain-check
495
+ # (Traefik ForwardAuth gate). Generate with: openssl rand -hex 32
496
+ DOMAIN_CHECK_SECRET=
497
+
498
+ # Mandatory shared secret for the Node middleware's calls to
499
+ # /api/customer_accounts/domain-resolve. Generate with: openssl rand -hex 32
500
+ DOMAIN_RESOLVE_SECRET=
501
+
502
+ # Internal origin the proxy uses to call domain-resolve. Defaults to
503
+ # http://app:3000 inside docker; set to your local app for `yarn dev`.
504
+ INTERNAL_APP_ORIGIN=http://127.0.0.1:3000
505
+
506
+ # DNS targets surfaced to admins in the back office.
507
+ CUSTOM_DOMAIN_CNAME_TARGET=portal.openmercato.local
508
+ CUSTOM_DOMAIN_A_RECORD_TARGET=
509
+
510
+ # Optional cache tuning (defaults: 60 / 300 / 10000)
511
+ # DOMAIN_CACHE_TTL_SECONDS=60
512
+ # DOMAIN_NEGATIVE_CACHE_TTL_SECONDS=300
513
+ # DOMAIN_CACHE_MAX_ENTRIES=10000
514
+
515
+ # Optional override for the marker header set on /_next/health
516
+ # CUSTOMER_DOMAIN_ORIGIN_HEADER=X-Open-Mercato-Origin
517
+
518
+ # Background verification (Phase 5) — both workers are scheduled
519
+ # automatically by `mercato init` via the system scheduler.
520
+ # DNS auto-verification scan interval (default 300s = 5 min).
521
+ # DOMAIN_AUTO_VERIFY_INTERVAL_SECONDS=300
522
+ # TLS retry scan interval (default 1800s = 30 min).
523
+ # DOMAIN_TLS_RETRY_INTERVAL_SECONDS=1800
524
+ # Per-run cap for the TLS retry worker (default 50).
525
+ # DOMAIN_TLS_RETRY_BATCH=50
526
+ # Failure-rate threshold (0–1) that triggers worker-level backoff (default 0.8).
527
+ # DOMAIN_TLS_RETRY_FAILURE_THRESHOLD=0.8
528
+ # Maximum tls_retry_count before the worker stops retrying (default 6).
529
+ # DOMAIN_TLS_MAX_RETRIES=6
530
+
531
+ # --- Traefik (only for docker-compose.fullapp{,.dev}.yml) ---
532
+ # Let's Encrypt contact address.
533
+ ACME_EMAIL=
534
+ # Defaults to LE production. Dev compose defaults to LE staging.
535
+ # TRAEFIK_CA_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory
536
+ # TRAEFIK_HTTP_PORT=80
537
+ # TRAEFIK_HTTPS_PORT=443
538
+ # TRAEFIK_DASHBOARD_PORT=8081
539
+
540
+ # --- Test-only Host spoof (NEVER set in production) ---
541
+ # Activates only when NODE_ENV=test. Used by Playwright to drive arbitrary
542
+ # Host values without DNS. Generate with: openssl rand -hex 32
543
+ # FORCE_HOST_SECRET=
@@ -22,6 +22,14 @@ TODO: Fix that latter to have reference by the package names
22
22
  z-index: var(--z-index-popover);
23
23
  }
24
24
 
25
+ /* Explicitly written outside @theme — Tailwind 4 only emits @theme inline
26
+ variables that some compiled utility references, so writing the value
27
+ inline here guarantees the rule lands in the bundle even if the source
28
+ scanner misses the className literal. */
29
+ @utility z-modal-elevated {
30
+ z-index: var(--z-index-modal-elevated, 55);
31
+ }
32
+
25
33
  @theme inline {
26
34
  --color-background: var(--background);
27
35
  --color-foreground: var(--foreground);
@@ -93,6 +101,11 @@ TODO: Fix that latter to have reference by the package names
93
101
  --color-status-neutral-border: var(--status-neutral-border);
94
102
  --color-status-neutral-icon: var(--status-neutral-icon);
95
103
 
104
+ --color-status-pink-bg: var(--status-pink-bg);
105
+ --color-status-pink-text: var(--status-pink-text);
106
+ --color-status-pink-border: var(--status-pink-border);
107
+ --color-status-pink-icon: var(--status-pink-icon);
108
+
96
109
  /* ═══ Design System: Accent Colors ═══ */
97
110
  --color-accent-indigo: var(--accent-indigo);
98
111
  --color-accent-indigo-foreground: var(--accent-indigo-foreground);
@@ -121,6 +134,7 @@ TODO: Fix that latter to have reference by the package names
121
134
  --z-index-overlay: 30;
122
135
  --z-index-modal: 40;
123
136
  --z-index-popover: 45;
137
+ --z-index-modal-elevated: 55;
124
138
  --z-index-toast: 50;
125
139
  --z-index-tooltip: 60;
126
140
  --z-index-banner: 70;
@@ -250,6 +264,12 @@ TODO: Fix that latter to have reference by the package names
250
264
  --status-neutral-text: oklch(0.445 0 0);
251
265
  --status-neutral-border: oklch(0.850 0 0);
252
266
  --status-neutral-icon: oklch(0.556 0 0); /* = --muted-foreground */
267
+
268
+ /* Pink (hue ~340° — Figma SPEC-048 ACCENT / PINK Stage badges; Tailwind pink-500/-200/-700/-50) */
269
+ --status-pink-bg: oklch(0.965 0.020 340); /* pink-50 #FDF2F8 */
270
+ --status-pink-text: oklch(0.388 0.180 340); /* pink-700 #BE185D */
271
+ --status-pink-border: oklch(0.840 0.090 340);/* pink-200 #FBCFE8 */
272
+ --status-pink-icon: oklch(0.633 0.238 340); /* pink-500 #EC4899 */
253
273
  }
254
274
 
255
275
  /* Opt-in Figma-style focus ring: 2px inner ring + 4px outer ring */
@@ -358,6 +378,12 @@ TODO: Fix that latter to have reference by the package names
358
378
  --status-neutral-text: oklch(0.750 0 0);
359
379
  --status-neutral-border: oklch(0.380 0 0);
360
380
  --status-neutral-icon: oklch(0.708 0 0); /* = dark --muted-foreground */
381
+
382
+ /* Pink (dark mode) — Figma SPEC-048 ACCENT / PINK Stage badges */
383
+ --status-pink-bg: oklch(0.225 0.045 340);
384
+ --status-pink-text: oklch(0.815 0.135 340);
385
+ --status-pink-border: oklch(0.405 0.155 340);
386
+ --status-pink-icon: oklch(0.700 0.225 340);
361
387
  }
362
388
 
363
389
  @layer base {
@@ -7,6 +7,7 @@ import { MessageCircle, Send } from 'lucide-react'
7
7
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@open-mercato/ui/primitives/dialog'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { Input } from '@open-mercato/ui/primitives/input'
10
+ import { EmailInput } from '@open-mercato/ui/primitives/email-input'
10
11
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
11
12
  import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
12
13
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
@@ -279,9 +280,8 @@ export function DemoFeedbackWidget({ demoModeEnabled }: { demoModeEnabled: boole
279
280
  )}
280
281
 
281
282
  <div className="grid gap-1">
282
- <Input
283
+ <EmailInput
283
284
  id="feedback-email"
284
- type="email"
285
285
  placeholder={t('demoFeedback.form.email', 'Your email')}
286
286
  value={email}
287
287
  onChange={(e) => setEmail(e.target.value)}
@@ -0,0 +1,239 @@
1
+ import { normalizeHostname, tryNormalizeHostname } from '@open-mercato/core/modules/customer_accounts/lib/hostname'
2
+
3
+ export type DomainResolution = {
4
+ hostname: string
5
+ tenantId: string
6
+ organizationId: string
7
+ orgSlug: string | null
8
+ status: 'active'
9
+ }
10
+
11
+ type PositiveEntry = {
12
+ isNegative: false
13
+ data: DomainResolution
14
+ expiresAt: number
15
+ }
16
+
17
+ type NegativeEntry = {
18
+ isNegative: true
19
+ expiresAt: number
20
+ }
21
+
22
+ type CacheEntry = PositiveEntry | NegativeEntry
23
+
24
+ export type CacheLookup =
25
+ | { kind: 'fresh-hit'; data: DomainResolution | null }
26
+ | { kind: 'stale-hit'; data: DomainResolution | null }
27
+ | { kind: 'cold-miss' }
28
+
29
+ export type DomainResolver = (hostname: string) => Promise<DomainResolution | null>
30
+
31
+ export type CustomDomainCacheOptions = {
32
+ positiveTtlMs: number
33
+ negativeTtlMs: number
34
+ maxEntries: number
35
+ resolver: DomainResolver
36
+ now?: () => number
37
+ onWarmupError?: (error: unknown) => void
38
+ onResolveError?: (hostname: string, error: unknown) => void
39
+ /**
40
+ * Maximum number of in-flight resolver promises. When exceeded, new unique
41
+ * hostnames are dropped (resolved as `null`) instead of starting another
42
+ * fetch. Prevents unbounded `inFlight` growth under hostname-flood probing.
43
+ * Defaults to `maxEntries` when omitted.
44
+ */
45
+ maxInFlight?: number
46
+ /**
47
+ * Invoked once when an in-flight slot is denied to a new hostname because
48
+ * the cap is saturated. The cache rate-limits its own warning, so this hook
49
+ * fires every time but the default warning logger does not spam.
50
+ */
51
+ onInFlightCapReached?: (hostname: string) => void
52
+ }
53
+
54
+ const POSITIVE_TTL_DEFAULT_SECONDS = 60
55
+ const NEGATIVE_TTL_DEFAULT_SECONDS = 60
56
+ // Hard ceiling for the per-process negative TTL. There is no in-process
57
+ // invalidation hook between Next.js workers — only the shared cache is
58
+ // invalidated when a domain becomes active — so a longer negative TTL means
59
+ // a newly registered domain can stay unresolved on a given process up to
60
+ // this many seconds. Keep it under one minute to bound that propagation
61
+ // delay for real customers while still absorbing unknown-hostname probing.
62
+ const NEGATIVE_TTL_MAX_SECONDS = 60
63
+ const MAX_ENTRIES_DEFAULT = 10_000
64
+
65
+ function readNumberEnv(name: string, fallbackSeconds: number, maxSeconds?: number): number {
66
+ const raw = process.env[name]
67
+ if (!raw) return fallbackSeconds * 1000
68
+ const parsed = Number(raw)
69
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallbackSeconds * 1000
70
+ const seconds = maxSeconds !== undefined ? Math.min(parsed, maxSeconds) : parsed
71
+ return seconds * 1000
72
+ }
73
+
74
+ function readMaxEntriesEnv(): number {
75
+ const raw = process.env.DOMAIN_CACHE_MAX_ENTRIES
76
+ if (!raw) return MAX_ENTRIES_DEFAULT
77
+ const parsed = Number(raw)
78
+ if (!Number.isFinite(parsed) || parsed <= 0) return MAX_ENTRIES_DEFAULT
79
+ return Math.floor(parsed)
80
+ }
81
+
82
+ const IN_FLIGHT_CAP_WARN_INTERVAL_MS = 60_000
83
+
84
+ export function createCustomDomainCache(options: CustomDomainCacheOptions) {
85
+ const {
86
+ positiveTtlMs,
87
+ negativeTtlMs,
88
+ maxEntries,
89
+ resolver,
90
+ now = Date.now,
91
+ onResolveError,
92
+ onInFlightCapReached,
93
+ } = options
94
+ const maxInFlight = Math.max(1, Math.floor(options.maxInFlight ?? maxEntries))
95
+
96
+ // Map preserves insertion order — re-inserting on access yields LRU semantics.
97
+ const store = new Map<string, CacheEntry>()
98
+ const inFlight = new Map<string, Promise<DomainResolution | null>>()
99
+ let lastCapWarnAt = 0
100
+
101
+ function touch(key: string, entry: CacheEntry): void {
102
+ store.delete(key)
103
+ store.set(key, entry)
104
+ while (store.size > maxEntries) {
105
+ const oldest = store.keys().next().value
106
+ if (oldest === undefined) break
107
+ store.delete(oldest)
108
+ }
109
+ }
110
+
111
+ function readEntry(hostname: string): CacheLookup {
112
+ const entry = store.get(hostname)
113
+ if (!entry) return { kind: 'cold-miss' }
114
+ // Re-insert to keep LRU order on each access.
115
+ store.delete(hostname)
116
+ store.set(hostname, entry)
117
+ const fresh = entry.expiresAt > now()
118
+ const data = entry.isNegative ? null : entry.data
119
+ return fresh ? { kind: 'fresh-hit', data } : { kind: 'stale-hit', data }
120
+ }
121
+
122
+ function writePositive(hostname: string, data: DomainResolution): void {
123
+ touch(hostname, { isNegative: false, data, expiresAt: now() + positiveTtlMs })
124
+ }
125
+
126
+ function writeNegative(hostname: string): void {
127
+ touch(hostname, { isNegative: true, expiresAt: now() + negativeTtlMs })
128
+ }
129
+
130
+ function reportInFlightCap(hostname: string): void {
131
+ if (onInFlightCapReached) {
132
+ onInFlightCapReached(hostname)
133
+ return
134
+ }
135
+ const ts = now()
136
+ if (ts - lastCapWarnAt >= IN_FLIGHT_CAP_WARN_INTERVAL_MS) {
137
+ lastCapWarnAt = ts
138
+ console.warn('[customDomainCache] inFlight cap reached, dropping fetch for', hostname)
139
+ }
140
+ }
141
+
142
+ async function fetchAndStore(hostname: string): Promise<DomainResolution | null> {
143
+ const existing = inFlight.get(hostname)
144
+ if (existing) return existing
145
+ if (inFlight.size >= maxInFlight) {
146
+ // Cap saturated: refuse to start another fetch. Returning null mirrors
147
+ // the resolver's "unknown host" path (HTTP 404) so callers degrade
148
+ // gracefully without poisoning the cache or pinning more promises.
149
+ reportInFlightCap(hostname)
150
+ return null
151
+ }
152
+ const promise = (async () => {
153
+ try {
154
+ const result = await resolver(hostname)
155
+ if (result) writePositive(hostname, result)
156
+ else writeNegative(hostname)
157
+ return result
158
+ } catch (err) {
159
+ onResolveError?.(hostname, err)
160
+ // Do not poison the cache on transient resolver errors — let the next
161
+ // request retry. Stale entries (if any) keep serving via SWR.
162
+ throw err
163
+ } finally {
164
+ inFlight.delete(hostname)
165
+ }
166
+ })()
167
+ inFlight.set(hostname, promise)
168
+ return promise
169
+ }
170
+
171
+ async function resolve(rawHostname: string): Promise<DomainResolution | null> {
172
+ const normalized = tryNormalizeHostname(rawHostname)
173
+ if (!normalized) return null
174
+ const lookup = readEntry(normalized)
175
+ if (lookup.kind === 'fresh-hit') {
176
+ return lookup.data
177
+ }
178
+ if (lookup.kind === 'stale-hit') {
179
+ // Serve stale immediately; trigger a non-blocking refresh.
180
+ void fetchAndStore(normalized).catch(() => {
181
+ /* error already reported via onResolveError */
182
+ })
183
+ return lookup.data
184
+ }
185
+ // Cold miss: synchronous fetch as fallback.
186
+ return fetchAndStore(normalized)
187
+ }
188
+
189
+ function primeFromList(entries: Iterable<DomainResolution>): void {
190
+ for (const entry of entries) {
191
+ const normalized = tryNormalizeHostname(entry.hostname)
192
+ if (!normalized) continue
193
+ writePositive(normalized, { ...entry, hostname: normalized })
194
+ }
195
+ }
196
+
197
+ function clear(): void {
198
+ store.clear()
199
+ inFlight.clear()
200
+ lastCapWarnAt = 0
201
+ }
202
+
203
+ function size(): number {
204
+ return store.size
205
+ }
206
+
207
+ function peek(hostname: string): CacheEntry | undefined {
208
+ const normalized = tryNormalizeHostname(hostname) ?? hostname
209
+ return store.get(normalized)
210
+ }
211
+
212
+ return {
213
+ resolve,
214
+ primeFromList,
215
+ clear,
216
+ size,
217
+ peek,
218
+ // Exposed for tests / instrumentation only.
219
+ _internals: { store, inFlight, writePositive, writeNegative, normalize: normalizeHostname },
220
+ }
221
+ }
222
+
223
+ export type CustomDomainCache = ReturnType<typeof createCustomDomainCache>
224
+
225
+ export function readPositiveTtlMs(): number {
226
+ return readNumberEnv('DOMAIN_CACHE_TTL_SECONDS', POSITIVE_TTL_DEFAULT_SECONDS)
227
+ }
228
+
229
+ export function readNegativeTtlMs(): number {
230
+ return readNumberEnv(
231
+ 'DOMAIN_NEGATIVE_CACHE_TTL_SECONDS',
232
+ NEGATIVE_TTL_DEFAULT_SECONDS,
233
+ NEGATIVE_TTL_MAX_SECONDS,
234
+ )
235
+ }
236
+
237
+ export function readMaxEntries(): number {
238
+ return readMaxEntriesEnv()
239
+ }
@@ -0,0 +1,195 @@
1
+ import { platformDomains } from '@open-mercato/core/modules/customer_accounts/lib/platformDomains'
2
+ import {
3
+ createCustomDomainCache,
4
+ readMaxEntries,
5
+ readNegativeTtlMs,
6
+ readPositiveTtlMs,
7
+ type CustomDomainCache,
8
+ type DomainResolution,
9
+ } from './customDomainCache'
10
+
11
+ const DEFAULT_FETCH_TIMEOUT_MS = 5_000
12
+
13
+ type ResolveResponse =
14
+ | { ok: true; tenantId: string; organizationId: string; orgSlug: string | null; status: 'active' }
15
+ | { ok: false; error: string }
16
+
17
+ type ResolveAllResponse =
18
+ | {
19
+ ok: true
20
+ domains: Array<{
21
+ hostname: string
22
+ tenantId: string
23
+ organizationId: string
24
+ orgSlug: string | null
25
+ status: 'active'
26
+ }>
27
+ }
28
+ | { ok: false; error: string }
29
+
30
+ function readInternalAppOrigin(): string | null {
31
+ const candidates = [
32
+ process.env.INTERNAL_APP_ORIGIN,
33
+ process.env.NEXT_INTERNAL_APP_ORIGIN,
34
+ ].filter((value): value is string => Boolean(value && value.trim().length > 0))
35
+ if (candidates.length > 0) return candidates[0]!.replace(/\/$/, '')
36
+ const port = process.env.PORT?.trim() || '3000'
37
+ return `http://127.0.0.1:${port}`
38
+ }
39
+
40
+ function readResolveSecret(): string | null {
41
+ const secret = process.env.DOMAIN_RESOLVE_SECRET?.trim()
42
+ return secret && secret.length > 0 ? secret : null
43
+ }
44
+
45
+ async function fetchWithTimeout(
46
+ fetchImpl: typeof fetch,
47
+ url: string,
48
+ init: RequestInit,
49
+ timeoutMs: number,
50
+ ): Promise<Response> {
51
+ const controller = new AbortController()
52
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
53
+ try {
54
+ return await fetchImpl(url, { ...init, signal: controller.signal })
55
+ } finally {
56
+ clearTimeout(timer)
57
+ }
58
+ }
59
+
60
+ export type CustomDomainRouterDeps = {
61
+ origin?: string | null
62
+ secret?: string | null
63
+ fetchTimeoutMs?: number
64
+ fetchImpl?: typeof fetch
65
+ logger?: Pick<Console, 'warn' | 'error'>
66
+ }
67
+
68
+ export type CustomDomainRouter = {
69
+ cache: CustomDomainCache
70
+ resolve(hostname: string): Promise<DomainResolution | null>
71
+ warmUp(): Promise<{ primed: number } | { primed: 0; error: string }>
72
+ reset(): void
73
+ }
74
+
75
+ export function createCustomDomainRouter(deps: CustomDomainRouterDeps = {}): CustomDomainRouter {
76
+ const origin = (deps.origin ?? readInternalAppOrigin())?.replace(/\/$/, '') ?? null
77
+ const secret = deps.secret ?? readResolveSecret()
78
+ const timeoutMs = deps.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
79
+ const fetcher = deps.fetchImpl ?? fetch
80
+ const logger = deps.logger ?? console
81
+
82
+ async function singleResolve(hostname: string): Promise<DomainResolution | null> {
83
+ if (!origin || !secret) return null
84
+ const url = `${origin}/api/customer_accounts/domain-resolve?host=${encodeURIComponent(hostname)}`
85
+ let response: Response
86
+ try {
87
+ response = await fetchWithTimeout(
88
+ fetcher,
89
+ url,
90
+ {
91
+ method: 'GET',
92
+ headers: { 'X-Domain-Resolve-Secret': secret, accept: 'application/json' },
93
+ },
94
+ timeoutMs,
95
+ )
96
+ } catch (err) {
97
+ throw new Error(`domain-resolve fetch failed: ${(err as Error)?.message ?? 'unknown'}`)
98
+ }
99
+ if (response.status === 404) return null
100
+ if (!response.ok) {
101
+ throw new Error(`domain-resolve returned HTTP ${response.status}`)
102
+ }
103
+ const body = (await response.json()) as ResolveResponse
104
+ if (!body.ok) return null
105
+ return {
106
+ hostname,
107
+ tenantId: body.tenantId,
108
+ organizationId: body.organizationId,
109
+ orgSlug: body.orgSlug,
110
+ status: body.status,
111
+ }
112
+ }
113
+
114
+ const cache = createCustomDomainCache({
115
+ positiveTtlMs: readPositiveTtlMs(),
116
+ negativeTtlMs: readNegativeTtlMs(),
117
+ maxEntries: readMaxEntries(),
118
+ resolver: singleResolve,
119
+ onResolveError: (hostname, err) => {
120
+ logger.warn?.(`[custom-domain] resolve failed for ${hostname}: ${(err as Error)?.message ?? err}`)
121
+ },
122
+ })
123
+
124
+ async function warmUp(): Promise<{ primed: number } | { primed: 0; error: string }> {
125
+ if (!origin || !secret) {
126
+ return { primed: 0, error: 'INTERNAL_APP_ORIGIN or DOMAIN_RESOLVE_SECRET is not configured' }
127
+ }
128
+ const url = `${origin}/api/customer_accounts/domain-resolve/all`
129
+ try {
130
+ const response = await fetchWithTimeout(
131
+ fetcher,
132
+ url,
133
+ {
134
+ method: 'GET',
135
+ headers: { 'X-Domain-Resolve-Secret': secret, accept: 'application/json' },
136
+ },
137
+ timeoutMs,
138
+ )
139
+ if (!response.ok) {
140
+ return { primed: 0, error: `warm-up returned HTTP ${response.status}` }
141
+ }
142
+ const body = (await response.json()) as ResolveAllResponse
143
+ if (!body.ok) return { primed: 0, error: body.error || 'warm-up returned ok=false' }
144
+ cache.primeFromList(body.domains)
145
+ return { primed: body.domains.length }
146
+ } catch (err) {
147
+ return { primed: 0, error: (err as Error)?.message ?? 'unknown' }
148
+ }
149
+ }
150
+
151
+ return {
152
+ cache,
153
+ resolve: cache.resolve,
154
+ warmUp,
155
+ reset: cache.clear,
156
+ }
157
+ }
158
+
159
+ let sharedRouter: CustomDomainRouter | null = null
160
+ let sharedWarmUpPromise: Promise<unknown> | null = null
161
+
162
+ export function getSharedCustomDomainRouter(): CustomDomainRouter {
163
+ if (!sharedRouter) {
164
+ sharedRouter = createCustomDomainRouter()
165
+ }
166
+ return sharedRouter
167
+ }
168
+
169
+ export function ensureWarmUp(): Promise<unknown> {
170
+ if (!sharedWarmUpPromise) {
171
+ const router = getSharedCustomDomainRouter()
172
+ sharedWarmUpPromise = router.warmUp().then((result) => {
173
+ if ('error' in result && result.primed === 0) {
174
+ console.warn(`[custom-domain] warm-up skipped: ${result.error}`)
175
+ } else if ('primed' in result) {
176
+ console.info(`[custom-domain] warm-up primed ${result.primed} domain(s)`)
177
+ }
178
+ return result
179
+ })
180
+ }
181
+ return sharedWarmUpPromise
182
+ }
183
+
184
+ export function resetSharedRouterForTests(): void {
185
+ sharedRouter = null
186
+ sharedWarmUpPromise = null
187
+ }
188
+
189
+ export function platformDomainList(): string[] {
190
+ return platformDomains()
191
+ }
192
+
193
+ export function isPlatformHost(hostname: string): boolean {
194
+ return platformDomainList().includes(hostname.toLowerCase())
195
+ }
@@ -16,8 +16,8 @@ test.describe('TC-UMES-013: Ephemeral login page suppresses global notice bars',
16
16
  await expect(page.getByText(/this instance is provided for demo purposes only/i)).toHaveCount(0)
17
17
 
18
18
  await page.getByLabel('Email').fill(DEFAULT_CREDENTIALS.admin.email)
19
- await page.getByLabel('Password').fill(DEFAULT_CREDENTIALS.admin.password)
20
- await page.getByLabel('Password').press('Enter')
19
+ await page.getByLabel('Password', { exact: true }).fill(DEFAULT_CREDENTIALS.admin.password)
20
+ await page.getByLabel('Password', { exact: true }).press('Enter')
21
21
 
22
22
  await expect(page).toHaveURL(/\/backend(?:\/.*)?$/, { timeout: 20_000 })
23
23
  })