From 9f98cf0615feb33de94fae9ff8b9d0c0156f93d3 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:43:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20remove=20the=20dead=20PortalSche?= =?UTF-8?q?ma=20=E2=80=94=20never-enforced=20no-op=20projection=20(#3464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PortalSchema and its StackSchema `portals` collection 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 consumer. Authoring a portal was already a documented no-op marked [EXPERIMENTAL — not enforced]. This implements issue #3464 disposition A (prune the dead schema) rather than B (build a runtime). Removes ui/portal.zod.ts + its test, the `portals` key on StackSchema / defineStack, every Portal* export, the showcase ClientPortal fixture, and the generated Portal artifacts (json-schema manifest keys, api-surface entries, reference doc). Hand-written docs/skills stop advertising portals as a live surface; the real guest_portal public-forms path is left untouched. Versioned minor per the #2377 enforce-or-remove precedent and the launch-window no-major guard. Co-Authored-By: Claude Fable 5 --- .changeset/prune-portal-schema-3464.md | 31 ++ content/docs/references/ui/index.mdx | 1 - content/docs/references/ui/meta.json | 1 - content/docs/references/ui/portal.mdx | 406 ------------------ content/docs/ui/index.mdx | 6 +- docs/NEXT_STEP.md | 2 +- examples/app-showcase/objectstack.config.ts | 2 - examples/app-showcase/src/coverage.ts | 4 +- .../app-showcase/src/docs/showcase_tour_ui.md | 5 +- .../app-showcase/src/security/positions.ts | 9 +- examples/app-showcase/src/ui/portals/index.ts | 27 -- packages/spec/api-surface.json | 28 -- packages/spec/json-schema.manifest.json | 13 - packages/spec/scripts/build-docs.ts | 2 +- packages/spec/src/stack.zod.ts | 6 +- packages/spec/src/ui/index.ts | 1 - packages/spec/src/ui/portal.test.ts | 130 ------ packages/spec/src/ui/portal.zod.ts | 349 --------------- skills/objectstack-platform/SKILL.md | 2 +- 19 files changed, 49 insertions(+), 976 deletions(-) create mode 100644 .changeset/prune-portal-schema-3464.md delete mode 100644 content/docs/references/ui/portal.mdx delete mode 100644 examples/app-showcase/src/ui/portals/index.ts delete mode 100644 packages/spec/src/ui/portal.test.ts delete mode 100644 packages/spec/src/ui/portal.zod.ts 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 `<meta name="description">`. | -| **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({ <Card href="/docs/ui/create-vs-edit-form" title="Create form ≠ edit form" description="Different layouts per form mode" /> <Card href="/docs/ui/field-grouping-and-order" title="Field grouping & order" description="Control form structure" /> <Card href="/docs/ui/audience-based-interfaces" title="Audience-based interfaces" description="Different UIs per audience" /> - <Card href="/docs/ui/public-data-collection" title="Collect data from the public" description="Anonymous portal routes end to end" /> + <Card href="/docs/ui/public-data-collection" title="Collect data from the public" description="Anonymous public form routes end to end" /> </Cards> ## 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<string, Array<{ section: string; pages: string[] }> { 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 - * `/<routePrefix>/*` 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: `<object_name>.<view_id>` */ - 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: `<object_name>.<action_id>` */ - 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 <title>.'), - description: z.string().optional().describe('Default <meta name="description">.'), - 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:<idp-id>". - z.string().regex(/^sso:[a-z][a-z0-9_-]*(?::[a-z0-9_-]+)?$/i, - 'SSO mode must be "sso:<provider>" or "sso:<protocol>:<idp-id>"'), -])); - -export const PortalLayoutSchema = lazySchema(() => z.union([ - z.literal('console'), - z.literal('minimal'), - z.literal('embedded'), - // Plugin shell: "custom:<plugin-id>" or "custom:<plugin-id>/<layout-id>". - z.string().regex(/^custom:[a-z][a-z0-9_-]*(?:\/[a-z0-9_-]+)?$/i, - 'Custom layout must be "custom:<plugin-id>" or "custom:<plugin-id>/<layout-id>"'), -])); - -// --------------------------------------------------------------------------- -// 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 `<iframe>`. Controls - * `X-Frame-Options` / `frame-ancestors` CSP headers. - */ - embeddable: z.boolean().optional().default(false), - - /** - * Allowed embed origins (CSP `frame-ancestors`). Ignored when - * `embeddable: false`. - */ - allowedEmbedOrigins: z.array(z.string()).optional(), - - /** Whether the portal is active. */ - active: z.boolean().optional().default(true), -}).describe('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).')); - -// --------------------------------------------------------------------------- -// Factory + types -// --------------------------------------------------------------------------- - -export const Portal = { - create: (config: z.input<typeof PortalSchema>): Portal => PortalSchema.parse(config), -} as const; - -export function definePortal(config: z.input<typeof PortalSchema>): Portal { - return PortalSchema.parse(config); -} - -export type Portal = z.infer<typeof PortalSchema>; -export type PortalInput = z.input<typeof PortalSchema>; -export type PortalTheme = z.infer<typeof PortalThemeSchema>; -export type PortalNavItem = z.infer<typeof PortalNavItemSchema>; -export type PortalViewNavItem = z.infer<typeof PortalViewNavItemSchema>; -export type PortalActionNavItem = z.infer<typeof PortalActionNavItemSchema>; -export type PortalDashboardNavItem = z.infer<typeof PortalDashboardNavItemSchema>; -export type PortalUrlNavItem = z.infer<typeof PortalUrlNavItemSchema>; -export type PortalAnonymousEntry = z.infer<typeof PortalAnonymousEntrySchema>; -export type PortalAnonymousRoute = z.infer<typeof PortalAnonymousRouteSchema>; -export type PortalRateLimit = z.infer<typeof PortalRateLimitSchema>; -export type PortalSeo = z.infer<typeof PortalSeoSchema>; -export type PortalAuthMode = z.infer<typeof PortalAuthModeSchema>; -export type PortalLayout = z.infer<typeof PortalLayoutSchema>; diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index affd00483b..dc67e0d382 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -184,7 +184,7 @@ export default defineStack({ `defineStack()` accepts an `ObjectStackDefinitionInput`. Each top-level key holds a collection of one metadata kind — `manifest`, `objects`, -`objectExtensions`, `views`, `apps`, `portals`, `pages`, `dashboards`, +`objectExtensions`, `views`, `apps`, `pages`, `dashboards`, `reports`, `datasets`, `actions`, `themes`, `flows`, `jobs`, `emailTemplates`, `docs`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `apis`, `webhooks`, `api`, `agents`,