diff --git a/.changeset/prune-portal-schema-3464.md b/.changeset/prune-portal-schema-3464.md
new file mode 100644
index 0000000000..a36809eaf1
--- /dev/null
+++ b/.changeset/prune-portal-schema-3464.md
@@ -0,0 +1,31 @@
+---
+"@objectstack/spec": minor
+---
+
+feat(spec)!: remove the dead PortalSchema (portal metadata was never enforced)
+
+`PortalSchema` and its top-level `portals` collection on `StackSchema` were a
+forward-looking design that was **never wired to a runtime** — no metadata-type
+registration, no dispatcher route family, no auth scope, and no
+LayoutDispatcher / NavigationBuilder / ThemeProvider ever consumed it. Authoring
+a portal was already documented as a no-op and marked
+`[EXPERIMENTAL — not enforced]`. This removes the dead schema rather than
+building a portal runtime (issue #3464, disposition **A — prune**).
+
+**Removed exports** (`@objectstack/spec`, from `ui/portal.zod`):
+`PortalSchema`, `Portal`, `definePortal`, and the `PortalInput` /
+`PortalTheme` / `PortalNavItem` (+ `PortalViewNavItem`, `PortalActionNavItem`,
+`PortalDashboardNavItem`, `PortalUrlNavItem`) / `PortalAnonymousEntry` /
+`PortalAnonymousRoute` / `PortalRateLimit` / `PortalSeo` / `PortalAuthMode` /
+`PortalLayout` schemas and inferred types. The `portals` key is removed from
+`StackSchema` / `defineStack()`.
+
+**Migration**: none required for behavior — authoring a portal had no runtime
+effect. Any `portals: [...]` entry in a `defineStack()` config was already
+ignored at runtime and should be deleted (with the schema gone it is an
+excess-property type error). To project a scoped UI to external users today,
+compose the existing `apps` / `views` surfaces and gate admission with
+`positions` + permission sets (`externalSharingModel` on the objects you
+expose).
+
+Refs #3464, #1893, #1878.
diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx
index d696fe65e1..f48b8ad1c6 100644
--- a/content/docs/references/ui/index.mdx
+++ b/content/docs/references/ui/index.mdx
@@ -19,7 +19,6 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
-
diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json
index dd2bf468a0..9fbd8f7c70 100644
--- a/content/docs/references/ui/meta.json
+++ b/content/docs/references/ui/meta.json
@@ -5,7 +5,6 @@
"action",
"app",
"page",
- "portal",
"view",
"---Visualization---",
"chart",
diff --git a/content/docs/references/ui/portal.mdx b/content/docs/references/ui/portal.mdx
deleted file mode 100644
index bd355d86e9..0000000000
--- a/content/docs/references/ui/portal.mdx
+++ /dev/null
@@ -1,406 +0,0 @@
----
-title: Portal
-description: Portal protocol schemas
----
-
-{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-
-@module ui/portal
-
-Portal Protocol — Metadata-driven external-user UI projection.
-
-A Portal is **not** a new application or permission model. It is a
-
-declarative projection of the existing app / view / action surface,
-
-scoped to a route prefix, a set of admitted positions, and an optional
-
-anonymous entry surface.
-
-Five invariants this schema preserves:
-
-1. Zero business code — layout is an enum + plugin id; theme is tokens;
-
-navigation is references to existing metadata.
-
-2. Data plane is untouched — portal cannot declare objects, fields,
-
-flows, or permissions. Data API (`/api/v1/data/...`) is unaware of
-
-portals.
-
-3. Portal ≠ permission boundary. The permission model is (permission
-
-sets distributed via positions, ADR-0090). Portals only narrow the
-
-UI projection; hiding a view in `navigation` is UX, not security.
-
-4. Stackable — the same user/position can be admitted by multiple
-
-portals. Routing or a picker decides which one is rendered.
-
-5. Template-first — a template author ships `customer.portal.ts` and
-
-the platform guarantees the rendering shell.
-
-Architectural reach (consumer guidance, not part of the schema):
-
-- Dispatcher / HonoServer: at boot, enumerate portals and register
-
-`//*` route families with a per-portal auth scope.
-
-- Auth middleware: admit the request if one of the caller's positions
-
-∈ `portal.positions`, or it matches `anonymousEntry.routes[*]`.
-
-- objectui LayoutDispatcher: select shell from `layout`.
-
-- objectui NavigationBuilder: render `navigation` (not the all-apps
-
-grid).
-
-- objectui ThemeProvider: inject `theme` as CSS variables.
-
-See framework issue
-
-https://github.com/objectstack-ai/objectstack/issues/1294
-
-for the design rationale.
-
-
-**Source:** `packages/spec/src/ui/portal.zod.ts`
-
-
-## TypeScript Usage
-
-```typescript
-import { Portal, PortalActionNavItem, PortalAnonymousEntry, PortalAnonymousRoute, PortalAuthMode, PortalDashboardNavItem, PortalLayout, PortalNavItem, PortalRateLimit, PortalSeo, PortalTheme, PortalUrlNavItem, PortalViewNavItem } from '@objectstack/spec/ui';
-import type { Portal, PortalActionNavItem, PortalAnonymousEntry, PortalAnonymousRoute, PortalAuthMode, PortalDashboardNavItem, PortalLayout, PortalNavItem, PortalRateLimit, PortalSeo, PortalTheme, PortalUrlNavItem, PortalViewNavItem } from '@objectstack/spec/ui';
-
-// Validate data
-const result = Portal.parse(data);
-```
-
----
-
-## Portal
-
-Portal projection. [EXPERIMENTAL — not enforced] PortalSchema is not registered as an authorable metadata type; no dispatcher route family, auth scope, LayoutDispatcher, NavigationBuilder or ThemeProvider consumes it yet. The entire schema is a forward-looking design (framework #1294); authoring a portal today is a no-op (liveness audit #1878/#1893).
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **kind** | `'portal'` | ✅ | Metadata kind discriminator. |
-| **id** | `string` | ✅ | Portal unique machine name (lowercase snake_case). |
-| **label** | `string` | ✅ | Portal display label. |
-| **description** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
-| **routePrefix** | `string` | ✅ | Root URL path for the portal (must start with "/"). |
-| **domain** | `string` | optional | Optional vanity domain (e.g. "support.acme.com"). |
-| **layout** | `'console' \| 'minimal' \| 'embedded' \| string` | ✅ | Shell layout for the portal. |
-| **theme** | `{ primaryColor?: string; accentColor?: string; backgroundColor?: string; surfaceColor?: string; … }` | optional | Theme tokens. |
-| **locale** | `'auto' \| string` | ✅ | Locale resolution strategy. |
-| **seo** | `{ title?: string; description?: string; openGraphImage?: string; robots: Enum<'index' \| 'noindex'> }` | optional | |
-| **authMode** | `'authenticated' \| 'magic-link' \| 'anonymous' \| string` | ✅ | Authentication mode for the portal. |
-| **positions** | `string[]` | ✅ | Positions admitted to the portal. |
-| **profiles** | `any` | optional | |
-| **anonymousEntry** | `{ routes: { path: string; viewRef?: string; actionRef?: string; rateLimit?: object; … }[]; defaultRateLimit?: object }` | optional | |
-| **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … }[]` | ✅ | Flat list of portal entry points (references to existing metadata). |
-| **defaultRoute** | `{ viewRef?: string; actionRef?: string; dashboardName?: string }` | optional | Landing surface when the user hits the portal root. |
-| **embeddable** | `boolean` | ✅ | |
-| **allowedEmbedOrigins** | `string[]` | optional | |
-| **active** | `boolean` | ✅ | |
-
-
----
-
-## PortalActionNavItem
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'action'` | ✅ | |
-| **actionRef** | `string` | ✅ | Reference to an existing action, e.g. "helpdesk_ticket.create". |
-
-
----
-
-## PortalAnonymousEntry
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **routes** | `{ path: string; viewRef?: string; actionRef?: string; rateLimit?: object; … }[]` | ✅ | List of anonymous-accessible routes. |
-| **defaultRateLimit** | `{ rule: string; scope: Enum<'ip' \| 'tenant' \| 'route'> }` | optional | |
-
-
----
-
-## PortalAnonymousRoute
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **path** | `string` | ✅ | Path within the portal, e.g. "/submit" or "/kb". |
-| **viewRef** | `string` | optional | Reference to a public view (read-only). |
-| **actionRef** | `string` | optional | Reference to an action to perform anonymously (mutation). |
-| **rateLimit** | `{ rule: string; scope: Enum<'ip' \| 'tenant' \| 'route'> }` | optional | Rate-limit for anonymous traffic on this route. |
-| **captcha** | `boolean` | ✅ | Require CAPTCHA / proof-of-work challenge before invoking. |
-| **bindIdentityFromField** | `string` | optional | Field name on the action input to use for magic-link identity binding (e.g. "customer_email"). |
-
-
----
-
-## PortalAuthMode
-
-### Union Options
-
-This schema accepts one of the following structures:
-
-#### Option 1
-
-Type: `'authenticated'`
-
----
-
-#### Option 2
-
-Type: `'magic-link'`
-
----
-
-#### Option 3
-
-Type: `'anonymous'`
-
----
-
-#### Option 4
-
-Type: `string`
-
----
-
-
----
-
-## PortalDashboardNavItem
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'dashboard'` | ✅ | |
-| **dashboardName** | `string` | ✅ | Existing dashboard id. |
-
-
----
-
-## PortalLayout
-
-### Union Options
-
-This schema accepts one of the following structures:
-
-#### Option 1
-
-Type: `'console'`
-
----
-
-#### Option 2
-
-Type: `'minimal'`
-
----
-
-#### Option 3
-
-Type: `'embedded'`
-
----
-
-#### Option 4
-
-Type: `string`
-
----
-
-
----
-
-## PortalNavItem
-
-### Union Options
-
-This schema accepts one of the following structures:
-
-#### Option 1
-
-**Type:** `view`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'view'` | ✅ | |
-| **viewRef** | `string` | ✅ | Reference to an existing view, e.g. "helpdesk_ticket.list.my_tickets". |
-
----
-
-#### Option 2
-
-**Type:** `action`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'action'` | ✅ | |
-| **actionRef** | `string` | ✅ | Reference to an existing action, e.g. "helpdesk_ticket.create". |
-
----
-
-#### Option 3
-
-**Type:** `dashboard`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'dashboard'` | ✅ | |
-| **dashboardName** | `string` | ✅ | Existing dashboard id. |
-
----
-
-#### Option 4
-
-**Type:** `url`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'url'` | ✅ | |
-| **url** | `string` | ✅ | Absolute or root-relative URL. |
-| **target** | `Enum<'_self' \| '_blank'>` | ✅ | |
-
----
-
-
----
-
-## PortalRateLimit
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **rule** | `string` | ✅ | Rate-limit rule string, e.g. "5/hour/ip", "100/day/tenant". |
-| **scope** | `Enum<'ip' \| 'tenant' \| 'route'>` | ✅ | Counter scope. "ip" buckets per requester; "tenant" per portal owner; "route" global per route. |
-
-
----
-
-## PortalSeo
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **title** | `string` | optional | Default ``. |
-| **description** | `string` | optional | Default ``. |
-| **openGraphImage** | `string` | optional | Default og:image URL. |
-| **robots** | `Enum<'index' \| 'noindex'>` | ✅ | |
-
-
----
-
-## PortalTheme
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **primaryColor** | `string` | optional | Primary brand color (hex, rgb, hsl). Mapped to --portal-primary. |
-| **accentColor** | `string` | optional | Accent color used for highlights and CTAs. |
-| **backgroundColor** | `string` | optional | Page background color. |
-| **surfaceColor** | `string` | optional | Card / surface background color. |
-| **textColor** | `string` | optional | Primary text color. |
-| **logoUrl** | `string` | optional | Absolute or relative URL to the portal header logo (SVG or PNG). |
-| **faviconUrl** | `string` | optional | Absolute or relative URL to the portal favicon. |
-| **fontFamily** | `string` | optional | CSS font-family stack for the portal. |
-| **customCss** | `string` | optional | OPTIONAL escape hatch: raw CSS appended last. Discouraged; prefer tokens. |
-
-
----
-
-## PortalUrlNavItem
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'url'` | ✅ | |
-| **url** | `string` | ✅ | Absolute or root-relative URL. |
-| **target** | `Enum<'_self' \| '_blank'>` | ✅ | |
-
-
----
-
-## PortalViewNavItem
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique identifier for this portal nav item (lowercase snake_case). |
-| **label** | `string` | ✅ | Display label. |
-| **icon** | `string` | optional | Icon name (Lucide). |
-| **order** | `number` | optional | Sort order; lower appears first. |
-| **badge** | `string \| number` | optional | Optional badge (e.g. unread count). |
-| **type** | `'view'` | ✅ | |
-| **viewRef** | `string` | ✅ | Reference to an existing view, e.g. "helpdesk_ticket.list.my_tickets". |
-
-
----
-
diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx
index 67236d06a1..b9fd415d47 100644
--- a/content/docs/ui/index.mdx
+++ b/content/docs/ui/index.mdx
@@ -1,6 +1,6 @@
---
title: UI Engine
-description: Apps, views, dashboards, themes, and public portals — server-driven UI declared as metadata and rendered by the ObjectUI runtime.
+description: Apps, views, dashboards, themes, and public forms — server-driven UI declared as metadata and rendered by the ObjectUI runtime.
---
# UI Engine
@@ -32,7 +32,7 @@ export const CrmApp = App.create({
- **Views** present object records as `grid` (the standard data table), `kanban`, `gallery`, `calendar`, `timeline`, `gantt`, `map`, `chart`, or `tree`. Forms are their own view kind with per-mode layouts ([Views](/docs/ui/views)).
- **Pages** compose free-form layouts from widgets; **Dashboards** combine charts, reports, and datasets for analytics.
- **Themes** define palettes, typography, and spacing as metadata — the showcase example ships light and dark theme variants.
-- **Portals** open a scoped slice of the app to external audiences, including **anonymous entry routes** for public data collection ([Forms](/docs/ui/forms), [public data collection](/docs/ui/public-data-collection)).
+- **Public forms** expose a scoped slice of an object to external audiences through **anonymous entry routes** for public data collection ([Forms](/docs/ui/forms), [public data collection](/docs/ui/public-data-collection)).
- The **Setup App** — the platform's built-in administration UI — is itself rendered from the same protocol ([Setup App](/docs/ui/setup-app)).
## What's in this module
@@ -53,7 +53,7 @@ export const CrmApp = App.create({
-
+
## Related
diff --git a/docs/NEXT_STEP.md b/docs/NEXT_STEP.md
index 225252bd1e..2fc0915a37 100644
--- a/docs/NEXT_STEP.md
+++ b/docs/NEXT_STEP.md
@@ -55,7 +55,7 @@
| 13.2 | Document Generation Templates (PDF, DOCX) | 🟡 High | `ui/document-template.zod.ts` |
| 13.3 | Report Scheduling & Subscriptions | 🟡 High | Enhance `ui/report.zod.ts` |
| 13.4 | Dashboard Drill-Down & Drill-Through | 🟡 High | Enhance `ui/dashboard.zod.ts` |
-| 13.5 | Portal / Community Protocol | 🟡 High | `ui/portal.zod.ts` |
+| 13.5 | Portal / Community Protocol | 🟡 High | `ui/portal.zod.ts` (rebuild **with a runtime**; the prior no-op stub was pruned per #3464) |
| 13.6 | Advanced Report Features (formulas, conditional formatting, snapshots) | 🟡 Medium | Enhance `ui/report.zod.ts` |
**Checklist:**
diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts
index 8338a4770a..9f66863815 100644
--- a/examples/app-showcase/objectstack.config.ts
+++ b/examples/app-showcase/objectstack.config.ts
@@ -43,7 +43,6 @@ import {
} from './src/security/index.js';
import { allThemes } from './src/ui/themes/index.js';
import { ShowcaseTranslationBundle } from './src/system/translations/index.js';
-import { allPortals } from './src/ui/portals/index.js';
import { ShowcaseSeedData } from './src/data/seed/index.js';
import { allCubes } from './src/data/analytics/showcase.cube.js';
import { allObjectExtensions } from './src/data/extensions/account.extension.js';
@@ -193,7 +192,6 @@ export default defineStack({
// UI
apps: [ShowcaseApp],
- portals: allPortals,
views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews],
pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage],
dashboards: [ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard],
diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts
index bcabc8cfce..0f0e619c05 100644
--- a/examples/app-showcase/src/coverage.ts
+++ b/examples/app-showcase/src/coverage.ts
@@ -254,8 +254,8 @@ export const COVERAGE = {
'security/* — positions + permission sets (CRUD + FLS + RLS + depth + VAMA + system/tab permissions + adminScope) + sharing rules (position & BU-subtree recipients) + per-object OWD/externalSharingModel + seeded sys_business_unit tree + access-matrix.json gate (ADR-0090)',
automation: 'automation/flows/index.ts (incl. approval nodes) + automation/webhooks/index.ts + automation/jobs/index.ts + system/emails/index.ts',
},
- i18nThemingPortals: {
- coveredBy: 'system/translations/index.ts (en + zh-CN), ui/themes/index.ts (light + dark), ui/portals/index.ts',
+ i18nAndTheming: {
+ coveredBy: 'system/translations/index.ts (en + zh-CN), ui/themes/index.ts (light + dark)',
},
docs: {
source: 'ADR-0046 (doc metadata)',
diff --git a/examples/app-showcase/src/docs/showcase_tour_ui.md b/examples/app-showcase/src/docs/showcase_tour_ui.md
index e91e1e8032..02af211b03 100644
--- a/examples/app-showcase/src/docs/showcase_tour_ui.md
+++ b/examples/app-showcase/src/docs/showcase_tour_ui.md
@@ -58,10 +58,9 @@ canonical example of each linked from that page.
- `src/ui/actions/` — the ActionType × location matrix (script / url /
modal / flow / api / form), visible as buttons across Task screens.
-## Themes & portals
+## Themes
-`src/ui/themes/` ships light + dark; `src/ui/portals/` exposes the public
-library portal that serves this very manual.
+`src/ui/themes/` ships light + dark theme variants.
Continue with the [Automation tour](./showcase_tour_automation.md), or go
back to the [overview](./showcase_index.md).
diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts
index 79cea727aa..83aa54947e 100644
--- a/examples/app-showcase/src/security/positions.ts
+++ b/examples/app-showcase/src/security/positions.ts
@@ -65,11 +65,10 @@ export const FieldOpsDelegatePosition = definePosition({
});
/**
- * External client audience — the position the Client Portal admits
- * (src/ui/portals/, `positions: ['client_portal_user']`). External portal
- * principals evaluate against each object's `externalSharingModel` dial
- * (ADR-0090 D11); this position is how the admin marks a user as belonging
- * to that audience.
+ * External client audience — a position for external client principals.
+ * External principals evaluate against each object's `externalSharingModel`
+ * dial (ADR-0090 D11); this position is how the admin marks a user as
+ * belonging to that audience.
*/
export const ClientPortalUserPosition = definePosition({
name: 'client_portal_user',
diff --git a/examples/app-showcase/src/ui/portals/index.ts b/examples/app-showcase/src/ui/portals/index.ts
deleted file mode 100644
index 4b6a6114e8..0000000000
--- a/examples/app-showcase/src/ui/portals/index.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * Client Portal — an external-user projection of the showcase. Demonstrates
- * the portal kind discriminator, position-scoped admission (ADR-0090 — the
- * former `profiles` gate was removed with the Profile concept), and
- * view-backed navigation. `client_portal_user` is a real showcase position
- * (src/security/positions.ts) the admin assigns to external client users.
- */
-export const ClientPortal = {
- kind: 'portal' as const,
- id: 'showcase_client_portal',
- label: 'Client Portal',
- description: 'External portal for clients to track their projects.',
- routePrefix: '/portal/client',
- layout: 'minimal',
- authMode: 'magic-link',
- locale: 'auto',
- positions: ['client_portal_user'],
- seo: { title: 'Client Portal — Showcase', description: 'Track your projects.', robots: 'noindex' as const },
- navigation: [
- { type: 'view' as const, id: 'my_projects', label: 'My Projects', icon: 'folder-kanban', order: 1, viewRef: 'showcase_project.list' },
- { type: 'view' as const, id: 'my_tasks', label: 'My Tasks', icon: 'check-square', order: 2, viewRef: 'showcase_task.grid' },
- ],
-};
-
-export const allPortals = [ClientPortal];
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index e10cd9d3c6..89030903df 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -3329,33 +3329,6 @@
"PinchGestureConfigSchema (const)",
"PluralRule (type)",
"PluralRuleSchema (const)",
- "Portal (type)",
- "PortalActionNavItem (type)",
- "PortalActionNavItemSchema (const)",
- "PortalAnonymousEntry (type)",
- "PortalAnonymousEntrySchema (const)",
- "PortalAnonymousRoute (type)",
- "PortalAnonymousRouteSchema (const)",
- "PortalAuthMode (type)",
- "PortalAuthModeSchema (const)",
- "PortalDashboardNavItem (type)",
- "PortalDashboardNavItemSchema (const)",
- "PortalInput (type)",
- "PortalLayout (type)",
- "PortalLayoutSchema (const)",
- "PortalNavItem (type)",
- "PortalNavItemSchema (const)",
- "PortalRateLimit (type)",
- "PortalRateLimitSchema (const)",
- "PortalSchema (const)",
- "PortalSeo (type)",
- "PortalSeoSchema (const)",
- "PortalTheme (type)",
- "PortalThemeSchema (const)",
- "PortalUrlNavItem (type)",
- "PortalUrlNavItemSchema (const)",
- "PortalViewNavItem (type)",
- "PortalViewNavItemSchema (const)",
"REACT_BLOCKS (const)",
"ReactBlockDef (interface)",
"ReactInteractionProp (interface)",
@@ -3485,7 +3458,6 @@
"defineDataset (function)",
"defineForm (function)",
"definePage (function)",
- "definePortal (function)",
"defineReport (function)",
"defineTheme (function)",
"defineView (function)",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 1f216ddd5e..d62fe5b58d 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -1729,19 +1729,6 @@
"ui/PersistStorage",
"ui/PinchGestureConfig",
"ui/PluralRule",
- "ui/Portal",
- "ui/PortalActionNavItem",
- "ui/PortalAnonymousEntry",
- "ui/PortalAnonymousRoute",
- "ui/PortalAuthMode",
- "ui/PortalDashboardNavItem",
- "ui/PortalLayout",
- "ui/PortalNavItem",
- "ui/PortalRateLimit",
- "ui/PortalSeo",
- "ui/PortalTheme",
- "ui/PortalUrlNavItem",
- "ui/PortalViewNavItem",
"ui/RecordActivityProps",
"ui/RecordChatterProps",
"ui/RecordDetailsProps",
diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts
index 2e793f33cb..67d63f1953 100644
--- a/packages/spec/scripts/build-docs.ts
+++ b/packages/spec/scripts/build-docs.ts
@@ -491,7 +491,7 @@ const SECTION_GROUPS: Record
{ section: 'Content & Collaboration', pages: ['doc', 'book', 'collaboration'] },
],
ui: [
- { section: 'Apps & Navigation', pages: ['app', 'page', 'portal', 'view', 'action'] },
+ { section: 'Apps & Navigation', pages: ['app', 'page', 'view', 'action'] },
{ section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component'] },
{ section: 'Interaction & Layout', pages: ['animation', 'dnd', 'keyboard', 'touch', 'responsive', 'theme', 'offline'] },
{ section: 'Platform', pages: ['i18n', 'notification', 'sharing', 'http'] },
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index 0f8ee8b20a..ecfa58df57 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -17,7 +17,6 @@ import { SeedSchema } from './data/seed.zod';
// UI Protocol
import { AppSchema } from './ui/app.zod';
-import { PortalSchema } from './ui/portal.zod';
import { ViewSchema } from './ui/view.zod';
import { PageSchema } from './ui/page.zod';
import { DashboardSchema } from './ui/dashboard.zod';
@@ -207,7 +206,10 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
* Apps, Menus, Pages, and Visualizations.
*/
apps: z.array(AppSchema).optional().describe('Applications'),
- portals: z.array(PortalSchema).optional().describe('External-user UI portals (projections of apps/views/actions)'),
+ // #3464: the top-level `portals` collection was removed — PortalSchema was a
+ // never-enforced, no-op projection (no dispatcher route family, auth scope,
+ // LayoutDispatcher, NavigationBuilder or ThemeProvider ever consumed it).
+ // Author external-user UI with apps/views + positions & permission sets.
views: z.array(ViewSchema).optional().describe('List Views'),
pages: z.array(PageSchema).optional().describe('Custom Pages'),
dashboards: z.array(DashboardSchema).optional().describe('Dashboards'),
diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts
index 231e298beb..a60dabc391 100644
--- a/packages/spec/src/ui/index.ts
+++ b/packages/spec/src/ui/index.ts
@@ -40,4 +40,3 @@ export * from './animation.zod';
export * from './notification.zod';
export * from './dnd.zod';
export * from './sharing.zod';
-export * from './portal.zod';
diff --git a/packages/spec/src/ui/portal.test.ts b/packages/spec/src/ui/portal.test.ts
deleted file mode 100644
index 375eb170a8..0000000000
--- a/packages/spec/src/ui/portal.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { describe, expect, it } from 'vitest';
-import { PortalSchema, definePortal } from './portal.zod';
-
-describe('PortalSchema', () => {
- it('accepts a minimal authenticated portal', () => {
- const portal = definePortal({
- kind: 'portal',
- id: 'helpdesk_customer',
- label: 'Help Center',
- routePrefix: '/portal/helpdesk',
- positions: ['helpdesk_customer_portal'],
- navigation: [
- {
- id: 'nav_my_tickets',
- type: 'view',
- label: 'My Tickets',
- viewRef: 'helpdesk_ticket.list.my_tickets',
- },
- ],
- });
- expect(portal.kind).toBe('portal');
- expect(portal.routePrefix).toBe('/portal/helpdesk');
- expect(portal.layout).toBe('minimal');
- expect(portal.authMode).toBe('authenticated');
- });
-
- it('accepts anonymousEntry with rate-limit and captcha', () => {
- const portal = definePortal({
- kind: 'portal',
- id: 'helpdesk_customer',
- label: 'Help Center',
- routePrefix: '/portal/helpdesk',
- authMode: 'magic-link',
- positions: ['helpdesk_customer_portal'],
- navigation: [
- { id: 'nav_my', type: 'view', label: 'My', viewRef: 'helpdesk_ticket.list.my_tickets' },
- ],
- anonymousEntry: {
- routes: [
- {
- path: '/submit',
- actionRef: 'helpdesk_ticket.create',
- rateLimit: { rule: '5/hour/ip', scope: 'ip' },
- captcha: true,
- bindIdentityFromField: 'customer_email',
- },
- {
- path: '/kb',
- viewRef: 'helpdesk_kb_article.list.published',
- },
- ],
- defaultRateLimit: { rule: '100/hour/ip', scope: 'ip' },
- },
- });
- expect(portal.anonymousEntry?.routes).toHaveLength(2);
- expect(portal.anonymousEntry?.routes[0].captcha).toBe(true);
- });
-
- it('rejects an invalid routePrefix', () => {
- expect(() =>
- definePortal({
- kind: 'portal',
- id: 'bad',
- label: 'bad',
- routePrefix: 'no-leading-slash',
- positions: ['x'],
- navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
- }),
- ).toThrow();
- });
-
- it('rejects an empty positions array', () => {
- expect(() =>
- definePortal({
- kind: 'portal',
- id: 'bad',
- label: 'bad',
- routePrefix: '/x',
- positions: [],
- navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
- }),
- ).toThrow();
- });
-
- it('rejects the removed `profiles` key with the FROM → TO prescription (ADR-0090 D2)', () => {
- expect(() =>
- definePortal({
- kind: 'portal',
- id: 'bad',
- label: 'bad',
- routePrefix: '/x',
- profiles: ['client_portal_user'],
- navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
- } as never),
- ).toThrow(/profiles.*removed.*ADR-0090 D2[\s\S]*positions/);
- });
-
- it('accepts SSO and custom plugin layouts', () => {
- const portal = definePortal({
- kind: 'portal',
- id: 'enterprise',
- label: 'Enterprise',
- routePrefix: '/portal/enterprise',
- authMode: 'sso:azure-ad',
- layout: 'custom:my-plugin/dashboard',
- positions: ['enterprise_user'],
- navigation: [
- { id: 'home', type: 'dashboard', label: 'Home', dashboardName: 'enterprise_home' },
- ],
- });
- expect(portal.authMode).toBe('sso:azure-ad');
- expect(portal.layout).toBe('custom:my-plugin/dashboard');
- });
-
- it('rejects malformed SSO mode', () => {
- expect(() =>
- PortalSchema.parse({
- kind: 'portal',
- id: 'x',
- label: 'x',
- routePrefix: '/x',
- authMode: 'sso:',
- positions: ['x'],
- navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
- }),
- ).toThrow();
- });
-});
diff --git a/packages/spec/src/ui/portal.zod.ts b/packages/spec/src/ui/portal.zod.ts
deleted file mode 100644
index a1686102da..0000000000
--- a/packages/spec/src/ui/portal.zod.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * @module ui/portal
- *
- * Portal Protocol — Metadata-driven external-user UI projection.
- *
- * A Portal is **not** a new application or permission model. It is a
- * declarative projection of the existing app / view / action surface,
- * scoped to a route prefix, a set of admitted positions, and an optional
- * anonymous entry surface.
- *
- * Five invariants this schema preserves:
- * 1. Zero business code — layout is an enum + plugin id; theme is tokens;
- * navigation is references to existing metadata.
- * 2. Data plane is untouched — portal cannot declare objects, fields,
- * flows, or permissions. Data API (`/api/v1/data/...`) is unaware of
- * portals.
- * 3. Portal ≠ permission boundary. The permission model is (permission
- * sets distributed via positions, ADR-0090). Portals only narrow the
- * UI projection; hiding a view in `navigation` is UX, not security.
- * 4. Stackable — the same user/position can be admitted by multiple
- * portals. Routing or a picker decides which one is rendered.
- * 5. Template-first — a template author ships `customer.portal.ts` and
- * the platform guarantees the rendering shell.
- *
- * Architectural reach (consumer guidance, not part of the schema):
- * - Dispatcher / HonoServer: at boot, enumerate portals and register
- * `//*` route families with a per-portal auth scope.
- * - Auth middleware: admit the request if one of the caller's positions
- * ∈ `portal.positions`, or it matches `anonymousEntry.routes[*]`.
- * - objectui LayoutDispatcher: select shell from `layout`.
- * - objectui NavigationBuilder: render `navigation` (not the all-apps
- * grid).
- * - objectui ThemeProvider: inject `theme` as CSS variables.
- *
- * See framework issue
- * https://github.com/objectstack-ai/objectstack/issues/1294
- * for the design rationale.
- */
-
-import { z } from 'zod';
-import { lazySchema } from '../shared/lazy-schema';
-import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
-import { I18nLabelSchema } from './i18n.zod';
-
-// ---------------------------------------------------------------------------
-// Theme tokens (portal-local; intentionally narrower than AppBranding to keep
-// portal metadata declarative and platform-renderable without custom CSS)
-// ---------------------------------------------------------------------------
-
-export const PortalThemeSchema = lazySchema(() => z.object({
- primaryColor: z.string().optional()
- .describe('Primary brand color (hex, rgb, hsl). Mapped to --portal-primary.'),
- accentColor: z.string().optional()
- .describe('Accent color used for highlights and CTAs.'),
- backgroundColor: z.string().optional()
- .describe('Page background color.'),
- surfaceColor: z.string().optional()
- .describe('Card / surface background color.'),
- textColor: z.string().optional()
- .describe('Primary text color.'),
- logoUrl: z.string().optional()
- .describe('Absolute or relative URL to the portal header logo (SVG or PNG).'),
- faviconUrl: z.string().optional()
- .describe('Absolute or relative URL to the portal favicon.'),
- fontFamily: z.string().optional()
- .describe('CSS font-family stack for the portal.'),
- customCss: z.string().optional()
- .describe('OPTIONAL escape hatch: raw CSS appended last. Discouraged; prefer tokens.'),
-}));
-
-// ---------------------------------------------------------------------------
-// Navigation — references to existing metadata. Discriminated union mirrors
-// app.zod.ts shape but is intentionally a much smaller surface: portals
-// expose a flat list of entry points, not a deep group tree.
-// ---------------------------------------------------------------------------
-
-const BasePortalNavItemSchema = z.object({
- id: SnakeCaseIdentifierSchema
- .describe('Unique identifier for this portal nav item (lowercase snake_case).'),
- label: I18nLabelSchema.describe('Display label.'),
- icon: z.string().optional().describe('Icon name (Lucide).'),
- order: z.number().optional().describe('Sort order; lower appears first.'),
- badge: z.union([z.string(), z.number()]).optional()
- .describe('Optional badge (e.g. unread count).'),
-});
-
-export const PortalViewNavItemSchema = BasePortalNavItemSchema.extend({
- type: z.literal('view'),
- /** Fully-qualified view reference: `.` */
- viewRef: z.string().describe('Reference to an existing view, e.g. "helpdesk_ticket.list.my_tickets".'),
-});
-
-export const PortalActionNavItemSchema = BasePortalNavItemSchema.extend({
- type: z.literal('action'),
- /** Fully-qualified action reference: `.` */
- actionRef: z.string().describe('Reference to an existing action, e.g. "helpdesk_ticket.create".'),
-});
-
-export const PortalDashboardNavItemSchema = BasePortalNavItemSchema.extend({
- type: z.literal('dashboard'),
- dashboardName: SnakeCaseIdentifierSchema.describe('Existing dashboard id.'),
-});
-
-export const PortalUrlNavItemSchema = BasePortalNavItemSchema.extend({
- type: z.literal('url'),
- url: z.string().describe('Absolute or root-relative URL.'),
- target: z.enum(['_self', '_blank']).optional().default('_self'),
-});
-
-export const PortalNavItemSchema = z.discriminatedUnion('type', [
- PortalViewNavItemSchema,
- PortalActionNavItemSchema,
- PortalDashboardNavItemSchema,
- PortalUrlNavItemSchema,
-]);
-
-// ---------------------------------------------------------------------------
-// Anonymous entry — declarative, with mandatory rate-limit + captcha hooks
-// for any unauthenticated mutation. Read-only views (e.g. public KB) only
-// need a rate-limit budget.
-// ---------------------------------------------------------------------------
-
-export const PortalRateLimitSchema = lazySchema(() => z.object({
- /** Token-bucket rule string, e.g. "5/hour/ip" or "100/day/tenant". */
- rule: z.string().describe('Rate-limit rule string, e.g. "5/hour/ip", "100/day/tenant".'),
- /** Scope key controlled by the runtime. */
- scope: z.enum(['ip', 'tenant', 'route']).default('ip')
- .describe('Counter scope. "ip" buckets per requester; "tenant" per portal owner; "route" global per route.'),
-}));
-
-export const PortalAnonymousRouteSchema = lazySchema(() => z.object({
- /** Portal-relative path, must begin with `/`. */
- path: z.string().describe('Path within the portal, e.g. "/submit" or "/kb".'),
- /** Exactly one of these must be set. */
- viewRef: z.string().optional()
- .describe('Reference to a public view (read-only).'),
- actionRef: z.string().optional()
- .describe('Reference to an action to perform anonymously (mutation).'),
- rateLimit: PortalRateLimitSchema.optional()
- .describe('Rate-limit for anonymous traffic on this route.'),
- captcha: z.boolean().optional().default(false)
- .describe('Require CAPTCHA / proof-of-work challenge before invoking.'),
- /**
- * For action routes that need a deferred identity bind (e.g. anonymous
- * ticket submission → magic-link verification of the supplied email).
- * The runtime captures the input field, sends a magic link, and on
- * verification re-attributes the created record to the new user.
- */
- bindIdentityFromField: z.string().optional()
- .describe('Field name on the action input to use for magic-link identity binding (e.g. "customer_email").'),
-}));
-
-export const PortalAnonymousEntrySchema = lazySchema(() => z.object({
- routes: z.array(PortalAnonymousRouteSchema)
- .describe('List of anonymous-accessible routes.'),
- /** Default rate-limit applied when a route does not specify one. */
- defaultRateLimit: PortalRateLimitSchema.optional(),
-}));
-
-// ---------------------------------------------------------------------------
-// SEO + locale + auth modes
-// ---------------------------------------------------------------------------
-
-export const PortalSeoSchema = lazySchema(() => z.object({
- title: z.string().optional().describe('Default .'),
- description: z.string().optional().describe('Default .'),
- openGraphImage: z.string().optional().describe('Default og:image URL.'),
- robots: z.enum(['index', 'noindex']).optional().default('index'),
-}));
-
-export const PortalAuthModeSchema = lazySchema(() => z.union([
- z.literal('authenticated'),
- z.literal('magic-link'),
- z.literal('anonymous'),
- // SSO provider, e.g. "sso:google", "sso:azure-ad", "sso:saml:".
- z.string().regex(/^sso:[a-z][a-z0-9_-]*(?::[a-z0-9_-]+)?$/i,
- 'SSO mode must be "sso:" or "sso::"'),
-]));
-
-export const PortalLayoutSchema = lazySchema(() => z.union([
- z.literal('console'),
- z.literal('minimal'),
- z.literal('embedded'),
- // Plugin shell: "custom:" or "custom:/".
- z.string().regex(/^custom:[a-z][a-z0-9_-]*(?:\/[a-z0-9_-]+)?$/i,
- 'Custom layout must be "custom:" or "custom:/"'),
-]));
-
-// ---------------------------------------------------------------------------
-// Portal — top-level metadata kind
-// ---------------------------------------------------------------------------
-
-export const PortalSchema = lazySchema(() => z.object({
- /** Discriminator for the metadata registry. */
- kind: z.literal('portal').describe('Metadata kind discriminator.'),
-
- /** Machine name (id). Unique per tenant. */
- id: SnakeCaseIdentifierSchema
- .describe('Portal unique machine name (lowercase snake_case).'),
-
- /** Display label, i18n. */
- label: I18nLabelSchema.describe('Portal display label.'),
-
- /** Optional description (i18n). */
- description: I18nLabelSchema.optional(),
-
- // ---------------- Routing ----------------
-
- /**
- * Root path the portal is mounted at. MUST be absolute and start with `/`.
- * Multiple portals cannot share the same `routePrefix` within a tenant.
- * Example: "/portal/helpdesk".
- */
- routePrefix: z.string()
- .regex(/^\/[a-z0-9/_-]*$/i, 'routePrefix must start with "/" and be url-safe')
- .describe('Root URL path for the portal (must start with "/").'),
-
- /**
- * Optional vanity domain. When set, the platform serves the portal on
- * this hostname in addition to (or instead of) `routePrefix` on the
- * default domain. Subject to TLS provisioning and DNS verification.
- */
- domain: z.string().optional()
- .describe('Optional vanity domain (e.g. "support.acme.com").'),
-
- // ---------------- Shell ----------------
-
- layout: PortalLayoutSchema.default('minimal')
- .describe('Shell layout for the portal.'),
-
- theme: PortalThemeSchema.optional().describe('Theme tokens.'),
-
- /**
- * Locale resolution. "auto" → use Accept-Language; otherwise force a
- * specific locale (e.g. "en", "zh-CN").
- */
- locale: z.union([z.literal('auto'), z.string()]).optional().default('auto')
- .describe('Locale resolution strategy.'),
-
- /** SEO metadata defaults for unauthenticated pages. */
- seo: PortalSeoSchema.optional(),
-
- // ---------------- Auth ----------------
-
- authMode: PortalAuthModeSchema.default('authenticated')
- .describe('Authentication mode for the portal.'),
-
- /**
- * Positions admitted to the portal (ADR-0090 — formerly `profiles`; the
- * Profile concept was removed by D2). A user is allowed in iff they hold
- * at least one of the listed positions. Use the built-in `guest` position
- * for anonymous-only portals (D9). **This is a UI gate, not the source of
- * truth — the data layer still enforces permission sets + sharing on
- * every API call.**
- */
- positions: z.array(SnakeCaseIdentifierSchema)
- .min(1, "A portal must admit at least one position (use the built-in 'guest' position for anonymous-only portals).")
- .describe('Positions admitted to the portal.'),
-
- /**
- * Tombstone for the removed `profiles` key (ADR-0090 D2): reject loudly
- * with the FROM → TO prescription instead of silently stripping — a
- * silently-dropped admission gate is exactly the class of authoring error
- * the vocabulary freeze exists to prevent.
- */
- profiles: z
- .unknown()
- .optional()
- .superRefine((v, ctx) => {
- if (v !== undefined) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message:
- "'profiles' was removed (ADR-0090 D2 — the Profile concept no longer exists). " +
- "Declare `positions` instead: the flat distribution groups admitted to this portal " +
- "(use the built-in 'guest' position for anonymous-only portals).",
- });
- }
- }),
-
- /**
- * Anonymous entry surface — declarative routes that can be hit without
- * a session. The runtime impersonates a tenant-local `system.anonymous`
- * principal, applies the route's rate-limit + captcha, then runs the
- * referenced view/action.
- */
- anonymousEntry: PortalAnonymousEntrySchema.optional(),
-
- // ---------------- Navigation ----------------
-
- navigation: z.array(PortalNavItemSchema)
- .describe('Flat list of portal entry points (references to existing metadata).'),
-
- /**
- * Optional default route. If omitted, the runtime picks the first
- * `navigation` entry.
- */
- defaultRoute: z.object({
- viewRef: z.string().optional(),
- actionRef: z.string().optional(),
- dashboardName: SnakeCaseIdentifierSchema.optional(),
- }).optional().describe('Landing surface when the user hits the portal root.'),
-
- // ---------------- Embedding ----------------
-
- /**
- * Whether the portal may be rendered inside an `