@open-mercato/cli 0.5.1-develop.2652.0276e72e45 → 0.5.1-develop.2663.2c29774b5b
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.
- package/dist/agentic/guides/ui.md +97 -0
- package/dist/lib/generators/module-registry.js +22 -4
- package/dist/lib/generators/module-registry.js.map +2 -2
- package/package.json +5 -5
- package/src/lib/generators/__tests__/module-subset.test.ts +6 -6
- package/src/lib/generators/__tests__/output-snapshots.test.ts +3 -1
- package/src/lib/generators/module-registry.ts +28 -2
|
@@ -206,6 +206,103 @@ import {
|
|
|
206
206
|
| `PortalNotificationBell` | `t` | Header bell icon with unread badge |
|
|
207
207
|
| `PortalNotificationPanel` | — | Notification dropdown panel |
|
|
208
208
|
|
|
209
|
+
### Portal Page Structure
|
|
210
|
+
|
|
211
|
+
Every portal page is two files under `frontend/[orgSlug]/portal/<path>/`:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
page.tsx # Client component ("use client")
|
|
215
|
+
page.meta.ts # PageMetadata — access control + sidebar nav
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Minimal page:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
"use client"
|
|
222
|
+
import { usePortalContext } from '@open-mercato/ui/portal/PortalContext'
|
|
223
|
+
import { PortalPageHeader } from '@open-mercato/ui/portal/components'
|
|
224
|
+
|
|
225
|
+
export default function MyPortalPage({ params }: { params: { orgSlug: string } }) {
|
|
226
|
+
const { auth } = usePortalContext()
|
|
227
|
+
const { user, resolvedFeatures } = auth
|
|
228
|
+
return <PortalPageHeader title="Orders" />
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Prefer `usePortalContext()` inside pages wrapped by `PortalLayoutShell` — it reads server-hydrated auth and avoids client loading flashes. Reach for `useCustomerAuth(orgSlug)` only when the server wrapper is unavailable.
|
|
233
|
+
|
|
234
|
+
Minimal `page.meta.ts`:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import type { PageMetadata } from '@open-mercato/shared/modules/registry'
|
|
238
|
+
|
|
239
|
+
export const metadata: PageMetadata = {
|
|
240
|
+
requireCustomerAuth: true,
|
|
241
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
242
|
+
titleKey: 'portal.orders.title',
|
|
243
|
+
title: 'Orders',
|
|
244
|
+
nav: { label: 'Orders', labelKey: 'portal.nav.orders', group: 'main', order: 20 },
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default metadata
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- Public pages (login, signup, verify, forgot/reset-password): omit `requireCustomerAuth`; set `navHidden: true`.
|
|
251
|
+
- Authenticated pages without sidebar presence (detail/create/edit): set `requireCustomerAuth: true`, **omit** `nav`.
|
|
252
|
+
- Sidebar-visible pages: include a `nav` block. Feature-gated pages are automatically hidden when the user lacks grants.
|
|
253
|
+
|
|
254
|
+
Reference: `packages/core/src/modules/portal/frontend/[orgSlug]/portal/{dashboard,profile}/page.{tsx,meta.ts}`.
|
|
255
|
+
|
|
256
|
+
### Portal Feature-Gating Contract
|
|
257
|
+
|
|
258
|
+
Single source of truth: `requireCustomerFeatures` in `page.meta.ts`. The same list is enforced in three layers:
|
|
259
|
+
|
|
260
|
+
| Layer | Where | Effect |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| Page access | `apps/mercato/src/app/(frontend)/[...slug]/page.tsx` | Server-side gate via `CustomerRbacService.userHasAllFeatures()` — missing feature blocks render |
|
|
263
|
+
| Sidebar entry | `/api/customer_accounts/portal/nav` → `buildPortalNav()` at `packages/ui/src/portal/utils/nav.ts` | Same check — missing feature omits the entry |
|
|
264
|
+
| Injection widgets | `usePortalInjectedMenuItems` / `usePortalDashboardWidgets` | `/api/customer_accounts/portal/feature-check` + `hasAllFeatures()` — missing feature filters the widget |
|
|
265
|
+
|
|
266
|
+
Granting a customer role a feature (e.g. `portal.orders.view`) is sufficient to (a) reach the page, (b) see the sidebar entry, (c) see widgets gated by that feature. No separate menu-injection widget is required for sidebar presence when the page is backed by `page.meta.ts` with a `nav` block.
|
|
267
|
+
|
|
268
|
+
**MUST** resolve features via `hasAllFeatures` / `matchFeature` from `@open-mercato/shared/security/features`. Raw `Array.includes()` or `Set.has()` on feature arrays misses wildcards (`portal.*`) and is a bug.
|
|
269
|
+
|
|
270
|
+
Declare features in `acl.ts`; ship defaults per role via `defaultCustomerRoleFeatures` in `setup.ts`. Never rely on client-side checks alone as the access gate.
|
|
271
|
+
|
|
272
|
+
### Portal SPA CSRF Posture
|
|
273
|
+
|
|
274
|
+
Dual cookies set by login (`packages/core/src/modules/customer_accounts/api/login.ts`):
|
|
275
|
+
|
|
276
|
+
| Cookie | Contents | TTL | Flags |
|
|
277
|
+
|---|---|---|---|
|
|
278
|
+
| `customer_auth_token` | Short-lived JWT | 8h | `httpOnly`, `sameSite: 'lax'`, `secure` in prod, `path: '/'` |
|
|
279
|
+
| `customer_session_token` | Raw session token (hashed at rest) | 30d (env: `CUSTOMER_SESSION_TTL_DAYS`) | same as above |
|
|
280
|
+
|
|
281
|
+
Primary CSRF defense: `SameSite=lax` + same-origin deployment. No explicit CSRF token — the browser blocks cross-origin POSTs.
|
|
282
|
+
|
|
283
|
+
Rules:
|
|
284
|
+
- Use `apiCall` for every write — it uses `credentials: 'same-origin'` and sets JSON headers.
|
|
285
|
+
- Never expose either cookie to JS. `httpOnly` is load-bearing; do not add companion cookies that mirror session state.
|
|
286
|
+
- Never accept cross-origin POSTs on portal routes. Cross-origin use cases are explicit exceptions: per-tenant origin allowlist + CSRF token + re-auth.
|
|
287
|
+
- `sameSite: 'lax'` lets GET navigations carry cookies — keep all state-changing side effects behind POST/PUT/PATCH/DELETE.
|
|
288
|
+
- Logout (`api/portal/logout.ts`) clears both cookies with `maxAge: 0`. Mirror this shape for any new logout-style endpoints.
|
|
289
|
+
|
|
290
|
+
Concurrent sessions are capped at `MAX_CUSTOMER_SESSIONS_PER_USER` (default 5) in `customerSessionService.createSession()`. New sessions above the cap soft-delete the oldest active session.
|
|
291
|
+
|
|
292
|
+
### Portal XSS Discipline (Injected Widgets)
|
|
293
|
+
|
|
294
|
+
Third-party widgets render inside the authenticated portal and inherit user cookies. Enforce stricter discipline than first-party code because widgets load from arbitrary modules.
|
|
295
|
+
|
|
296
|
+
- **Forbidden**: `dangerouslySetInnerHTML` anywhere in portal injection widgets. Render structured data, not raw HTML.
|
|
297
|
+
- **Labels and user-facing text**: always through `useT()`; render as text children, never as HTML.
|
|
298
|
+
- **Icons**: Lucide components (`lucide-react`). No inline `<svg>` composed from user-controlled strings.
|
|
299
|
+
- **Asset URLs** (`src`, `href`, `action`, `srcDoc`): must not be user-controlled unless validated server-side against an allowlist.
|
|
300
|
+
- **No `eval`, `new Function`, `setTimeout(string)`**, or similar dynamic code paths.
|
|
301
|
+
- **Event-handler payloads** from SSE: validate shape (`isPortalBroadcastEvent` guards dispatch; never trust `event.data` to be well-formed without schema validation).
|
|
302
|
+
- **Styles**: no user-controlled strings in `style` props, CSS variables, or `className` built from untrusted input.
|
|
303
|
+
|
|
304
|
+
Prefer components that accept structured props over ones accepting `children` / `innerHTML` — the host keeps control of escaping.
|
|
305
|
+
|
|
209
306
|
### PortalShell Usage
|
|
210
307
|
|
|
211
308
|
```tsx
|
|
@@ -679,7 +679,7 @@ function detectExportedHttpMethods(sourceFile) {
|
|
|
679
679
|
return knownMethods.filter((method) => exportedMethods.has(method));
|
|
680
680
|
}
|
|
681
681
|
function buildPageRouteProps(metaExpr, routePath) {
|
|
682
|
-
return `pattern: ${toLiteral(routePath || "/")}, requireAuth: (${metaExpr})?.requireAuth, requireRoles: (${metaExpr})?.requireRoles, requireFeatures: (${metaExpr})?.requireFeatures, requireCustomerAuth: (${metaExpr})?.requireCustomerAuth, requireCustomerFeatures: (${metaExpr})?.requireCustomerFeatures, title: (${metaExpr})?.pageTitle ?? (${metaExpr})?.title, titleKey: (${metaExpr})?.pageTitleKey ?? (${metaExpr})?.titleKey, group: (${metaExpr})?.pageGroup ?? (${metaExpr})?.group, groupKey: (${metaExpr})?.pageGroupKey ?? (${metaExpr})?.groupKey, icon: (${metaExpr})?.icon, order: (${metaExpr})?.pageOrder ?? (${metaExpr})?.order, priority: (${metaExpr})?.pagePriority ?? (${metaExpr})?.priority, navHidden: (${metaExpr})?.navHidden, visible: (${metaExpr})?.visible, enabled: (${metaExpr})?.enabled, breadcrumb: (${metaExpr})?.breadcrumb, pageContext: (${metaExpr})?.pageContext, placement: (${metaExpr})?.placement`;
|
|
682
|
+
return `pattern: ${toLiteral(routePath || "/")}, requireAuth: (${metaExpr})?.requireAuth, requireRoles: (${metaExpr})?.requireRoles, requireFeatures: (${metaExpr})?.requireFeatures, requireCustomerAuth: (${metaExpr})?.requireCustomerAuth, requireCustomerFeatures: (${metaExpr})?.requireCustomerFeatures, nav: (${metaExpr})?.nav, title: (${metaExpr})?.pageTitle ?? (${metaExpr})?.title, titleKey: (${metaExpr})?.pageTitleKey ?? (${metaExpr})?.titleKey, group: (${metaExpr})?.pageGroup ?? (${metaExpr})?.group, groupKey: (${metaExpr})?.pageGroupKey ?? (${metaExpr})?.groupKey, icon: (${metaExpr})?.icon, order: (${metaExpr})?.pageOrder ?? (${metaExpr})?.order, priority: (${metaExpr})?.pagePriority ?? (${metaExpr})?.priority, navHidden: (${metaExpr})?.navHidden, visible: (${metaExpr})?.visible, enabled: (${metaExpr})?.enabled, breadcrumb: (${metaExpr})?.breadcrumb, pageContext: (${metaExpr})?.pageContext, placement: (${metaExpr})?.placement`;
|
|
683
683
|
}
|
|
684
684
|
function normalizeBreadcrumb(raw) {
|
|
685
685
|
if (!Array.isArray(raw)) return void 0;
|
|
@@ -706,6 +706,19 @@ function normalizePlacement(raw) {
|
|
|
706
706
|
order: typeof source.order === "number" ? source.order : void 0
|
|
707
707
|
};
|
|
708
708
|
}
|
|
709
|
+
function normalizePortalNav(raw) {
|
|
710
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
711
|
+
const source = raw;
|
|
712
|
+
if (typeof source.label !== "string" || source.label.length === 0) return void 0;
|
|
713
|
+
const group = source.group === "main" || source.group === "account" ? source.group : void 0;
|
|
714
|
+
return {
|
|
715
|
+
label: source.label,
|
|
716
|
+
labelKey: typeof source.labelKey === "string" ? source.labelKey : void 0,
|
|
717
|
+
group,
|
|
718
|
+
order: typeof source.order === "number" ? source.order : void 0,
|
|
719
|
+
icon: typeof source.icon === "string" ? source.icon : void 0
|
|
720
|
+
};
|
|
721
|
+
}
|
|
709
722
|
function normalizePageMetadata(raw) {
|
|
710
723
|
if (!raw || typeof raw !== "object") return null;
|
|
711
724
|
const source = raw;
|
|
@@ -735,6 +748,8 @@ function normalizePageMetadata(raw) {
|
|
|
735
748
|
if (breadcrumb) normalized.breadcrumb = breadcrumb;
|
|
736
749
|
const placement = normalizePlacement(source.placement);
|
|
737
750
|
if (placement) normalized.placement = placement;
|
|
751
|
+
const nav = normalizePortalNav(source.nav);
|
|
752
|
+
if (nav) normalized.nav = nav;
|
|
738
753
|
if (typeof source.icon === "string") normalized.icon = source.icon;
|
|
739
754
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
740
755
|
}
|
|
@@ -1446,6 +1461,7 @@ function buildPageRouteEntries(metaExpr, routePath) {
|
|
|
1446
1461
|
{ name: "requireFeatures", value: optionalPropertyAccess(meta, "requireFeatures") },
|
|
1447
1462
|
{ name: "requireCustomerAuth", value: optionalPropertyAccess(meta, "requireCustomerAuth") },
|
|
1448
1463
|
{ name: "requireCustomerFeatures", value: optionalPropertyAccess(meta, "requireCustomerFeatures") },
|
|
1464
|
+
{ name: "nav", value: optionalPropertyAccess(meta, "nav") },
|
|
1449
1465
|
{
|
|
1450
1466
|
name: "title",
|
|
1451
1467
|
value: nullishCoalesce([
|
|
@@ -2265,13 +2281,15 @@ async function generateModuleRegistry(options) {
|
|
|
2265
2281
|
{
|
|
2266
2282
|
const bootstrapPlugins = [...pluginRegistry.values()].filter((p) => p.bootstrapRegistration);
|
|
2267
2283
|
const allEntryImports = [
|
|
2268
|
-
buildImportStatement(`{ backendRoutes }`, `./backend-routes.generated`)
|
|
2284
|
+
buildImportStatement(`{ backendRoutes }`, `./backend-routes.generated`),
|
|
2285
|
+
buildImportStatement(`{ frontendRoutes }`, `./frontend-routes.generated`)
|
|
2269
2286
|
];
|
|
2270
2287
|
const allRegImports = [
|
|
2271
|
-
`import { registerBackendRouteManifests } from '@open-mercato/shared/modules/registry'`
|
|
2288
|
+
`import { registerBackendRouteManifests, registerFrontendRouteManifests } from '@open-mercato/shared/modules/registry'`
|
|
2272
2289
|
];
|
|
2273
2290
|
const allCalls = [
|
|
2274
|
-
`registerBackendRouteManifests(backendRoutes)
|
|
2291
|
+
`registerBackendRouteManifests(backendRoutes)`,
|
|
2292
|
+
`registerFrontendRouteManifests(frontendRoutes)`
|
|
2275
2293
|
];
|
|
2276
2294
|
for (const plugin of bootstrapPlugins) {
|
|
2277
2295
|
const reg = plugin.bootstrapRegistration;
|