Building your app
Landing widgets
An embedded app can contribute blocks to the tenant's public landing-page builder: your widget appears in the editor's "Your apps" category (only for tenants that installed your app), the tenant drags it onto their public site, and visitors see it like any platform block — without the tenant writing a single line of code. You declare the widget in the manifest and ship one Livewire component; the platform provides the rest:
- The editor card, carrying your app's name and icon.
- The block settings form, generated automatically from your
settings_schemadeclaration — you write no editor Blade at all. - Block behaviour (move / copy / columns / show-hide) comes from the
builder itself; the widget is stored inside the page document as one
bridge block of type
partner-widget— installing a new app adds no block class to the platform. - Security, free and mandatory: the runtime context on the public page is built by the platform, not by you.
Embedded apps only. A landing widget renders in-process inside okta-web as a Livewire component; an app hosted elsewhere has nothing to render there. Declaring
landing_widgetswith any otherintegrationTypefails the version publish.
Read the security model before you build. It decides what your widget can reach at all — people and money data will never reach your widget on a public page, whatever scopes your installation holds.
Declaring widgets in the manifest
{
"integrationType": "embedded",
// ... rest of the manifest ...
"landing_widgets": [
{
"key": "admission_register",
"label": "التسجيل في القبول",
"label_en": "Admission Registration",
"description": "نموذج تقديم طلب قبول من موقع الجهة العام.",
"description_en": "Admission application form on the tenant public site.",
"icon": "clipboard",
"category": "conversion",
"livewire_component": "my-app-landing-register",
"interaction": "static",
"scopes": ["education.grades.read"],
"cache_ttl": 300,
"settings_schema": {
"fields": [
{ "key": "title", "type": "text", "label": "العنوان", "label_en": "Title",
"localized": true, "default": "سجّل الآن", "min": 1, "max": 80 },
{ "key": "show_deadline", "type": "toggle", "label": "إظهار آخر موعد", "default": true }
]
},
"preview": { "thumbnail_url": "https://cdn.example.com/widgets/register.png" }
}
]
}
Fields
landing_widgets is an array of at most 10 widgets per app.
| Field | Required | Value |
|---|---|---|
key |
✓ | ^[a-z][a-z0-9_]{1,39}$ — unique within the app. The global identifier is the composite <module-slug>:<key>; stored blocks on tenant pages reference the widget by it. Changing it after publish = a new widget and every old block loses its reference — name it well in the first release. |
label / label_en |
label ✓ |
Card name in the editor (2–60 chars). label_en is used per locale and falls back to label. |
description / description_en |
— | Short text under the card (≤ 300 chars). |
icon |
— | Icon name (≤ 40 chars, default puzzle). An unknown value falls back to the default icon with no error. |
category |
— | The card's editor group: hero | content | media | conversion | layout (default conversion). |
livewire_component |
✓ | Livewire component alias, flat form only. An alias containing :: is refused at publish — Livewire 4 never resolves it anyway (the same trap as the App Control Panel). |
interaction |
— | static (default) or interactive. Interactive is a much heavier contract — see Interactive widgets. |
scopes |
— | Read scopes the render needs at runtime (≤ 20), each ending strictly in .read — any other verb fails the publish. This is a restriction, not a grant: what you declare here enters the read-leg intersection, and what you do not declare never reaches the widget even when the installation holds it. |
public_writes |
— | Write scopes the widget performs on behalf of an anonymous visitor (≤ 10), each ending strictly in .write. Three publish-time conditions: the shape; every entry being an active scope marked public_writable in the okta-web catalog (checked against the live catalog — and refused rather than accepted unchecked if the catalog cannot be reached); and interaction=interactive — a static widget is served from cache with no round trip in which a write could ever happen. |
cache_ttl |
— | Seconds the rendered HTML is cached on the public page (0–86400, default 300). Ignored entirely for interactive widgets — a cached body would replay one visitor's form state to another. |
settings_schema |
— | The declarative settings form — next section. |
preview.thumbnail_url |
— | Card thumbnail. Absolute https URL only. |
The settings form: settings_schema
{ "fields": [ { /* field */ }, ... ] } // at most 20 fields
The types are a closed vocabulary — exactly eight:
text, textarea, number, toggle, select, color, image, link.
There is no html type and there never will be (declaring it is
refused at publish with an explicit message). That is a security
decision, not a gap: every setting value passes through Blade escaping at
render time, so a tenant admin (or whoever compromises their account)
cannot inject script into the public page through your widget's
settings. If you need rich output, build it in your component from
structured fields.
Field properties:
| Property | Applies to | Description |
|---|---|---|
key |
all | ^[a-z][a-z0-9_]{0,39}$, unique within the widget. |
type |
all | One of the eight types. |
label / label_en |
all | Field title in the inspector (label required, ≤ 120 chars). |
localized |
all | true = the editor writes the value for the open locale only; false/absent = it mirrors the value to both locales. Your component always receives single-locale settings — the locale of the page being served — and never needs to know which fields are localized. |
default |
all | Initial value on insert. Checked against the type: a toggle accepts only a bool, a number only a number, and a select's default must be one of its declared options. |
help / help_en |
all | Help text under the field (≤ 300 chars). |
min / max |
text, textarea, number |
Length bounds for text, value bounds for numbers. On other types they are silently dropped. |
options |
select |
[{"value": "...", "label": "...", "label_en": "..."}] static list (≤ 50 options, unique values). A select without options fails the publish. |
options_endpoint |
select |
Reserved and currently refused. The shape (<provider>:<name>) is validated, then the declaration is explicitly refused at publish while no resolver exists on the platform — a readable publish error today beats a field that silently degrades into a text box in every tenant's editor. Use static options. |
An example covering all eight types:
{ "fields": [
{ "key": "title", "type": "text", "label": "العنوان", "localized": true,
"default": "سجّل الآن", "min": 1, "max": 80 },
{ "key": "intro", "type": "textarea", "label": "المقدمة", "localized": true, "max": 400 },
{ "key": "max_rows", "type": "number", "label": "عدد الصفوف", "default": 5, "min": 1, "max": 20 },
{ "key": "show_fees","type": "toggle", "label": "إظهار الرسوم", "default": false },
{ "key": "layout", "type": "select", "label": "التخطيط",
"options": [
{ "value": "grid", "label": "شبكة", "label_en": "Grid" },
{ "value": "list", "label": "قائمة", "label_en": "List" }
],
"default": "grid" },
{ "key": "accent", "type": "color", "label": "لون التمييز", "default": "#0ea5e9" },
{ "key": "banner", "type": "image", "label": "صورة الترويسة" },
{ "key": "policy", "type": "link", "label": "رابط سياسة القبول", "localized": true }
]}
Two points govern how storage works:
- The block is stored per locale inside the page document, and non-localized fields are duplicated across both locales with the same value — there is no "shared" store.
- A block inserted before you added a new field in a later release
will not carry its key. Therefore: every
settingsread in your component goes through?? $default, always.
Writing the component
The widget is an ordinary Livewire component inside your app, under the full Embedded code contract — being on a public page is no exemption:
- Data exclusively through
App\Services\PartnerApi\*— the policy scanner fails the build on anything else, and every service checks its scope internally, so whatever is not in the context is refused automatically. - Never assume a user. On the public page
auth()->user()is alwaysnull. Code that assumes a session breaks there even though it works in the editor preview. - Developer settings work:
GetAppSetting/GetAppSettingsrun inside the widget like any runtime context — reading your own app's store only, so you can change widget behaviour without publishing a new version. - UI to platform standards:
<x-…>components and semantic tokens — the widget renders inside the tenant's page with their theme and brand, and hard-coded colours break that. - Assume failure: an exception from your component on the public page shows no error page — it produces a silent void where the widget was (see below).
A static widget (static) renders once and its output is cached
for cache_ttl seconds: any wire:click or wire:model in a static
blade freezes into dead attributes in cached HTML. Right for display:
lists, stats, cards, links. Its component receives exactly
settings + locale at render time and nothing else — anything it
needs about the tenant it reads from the active ModuleContext like
every other PartnerApi service.
The security model (two asymmetric surfaces)
The two render surfaces are not symmetric, and the difference between them is the whole design:
- Editor preview: the tenant admin is signed in, so the context resolves the usual way (the same path as the rest of your app's pages). The preview sees what your app normally sees.
- Public page: the visitor is entirely anonymous — no user, no session. The platform rebuilds the runtime context per request from the tenant alone (resolved from the domain), and the context's scopes are the union of two sets, each an independent three-way intersection:
READ = (granted to the install) ∩ (catalog public_surface) ∩ (widget scopes)
WRITE = (granted to the install) ∩ (catalog public_writable) ∩ (widget public_writes)
context = READ ∪ WRITE
They are deliberately NOT one path with a merged flag: read and write
answer different questions ("may a stranger see this?" versus "may a
stranger cause this?"), and a single path would let a future edit to
one silently move the other. The verb filter runs twice on each
leg: a .write inside scopes[] is not a read declaration whatever
the manifest says, and a mis-marked catalog row cannot ride a grant
into the context.
The write leg carries two extra conditions: a platform-wide interactive flag checked inside the resolver itself, and a public write ticket that is only minted after the barriers pass (see Interactive widgets). No ticket = an empty write set — which is what every read path gets, by default, forever.
The governing principle: what never enters the context needs no guard. No new permission check, no extra middleware, no deny-list to maintain — a deliberately poor context, and the same existing guard that protects every PartnerApi service refuses everything outside it.
The practical consequence you must know before you build: student,
guardian, employee and financial scopes are never marked
public_surface or public_writable. Their exclusion is
structural, not policy: it is not a middleware rule someone could
miss — they simply never enter the context, so every call to them is
refused as if they were never granted. A widget calling ListStudents
on a public page will fail always, whatever you declare in
scopes. If your widget concept needs people data on a public page,
the concept itself needs rethinking.
Today's public_surface list (may grow by platform decision, very
conservatively):
countries.directory.read— public reference data (country list).countries.education_levels.read— the reference ladder of education stages.education.grades.read— the tenant's own grade levels (institutional structure a school already publishes on its site — no person and no record in it).
The scopes and public_writes fields in your declaration are the
third leg of their intersections, and they work in your favour: declare
the minimum, and a vulnerability in your widget's code can only reach
what you declared — even when your installation holds more.
Failure is a silent void — and cached
A widget failure on the public page — an exception, a refused scope, an alias that does not resolve, an uninstalled app — produces a silent void where the block was, and never takes the tenant's site down. A static widget's failure is also cached for 30 seconds to prevent hammering: a crashing widget is not re-executed for every visitor. Turn that around for development: your fix may not show up immediately because of the failure cache, and you will think "the widget just doesn't work" while the real cause is a swallowed exception — check the okta-web logs, and do not use repeated page refreshes as a test.
On the other surface — an interactive widget's round trip — failure is not silent: a request that cannot be attributed to a widget the tenant actually owns is refused with 403. Silence for the page, candour for the round trip.
Interactive widgets
A widget that accepts input from an anonymous visitor (a submission form, say). A much heavier contract than flipping the field, and every sentence of it is enforced by code, not by review.
The base class is mandatory — and CI enforces it
Your component must extend
App\Support\LandingWidgets\PublicWidgetComponent — a class the
platform owns. The landing-widget-base-class rule in the policy
scanner fails the build for any class under app/LandingWidgets/ in
your app that does not extend it: a plain Livewire\Component there is
not "a widget without the extras" — it is a public write path with no
context derivation and no barriers, failing open instead of closed. (An
intermediate base class of your own that extends PublicWidgetComponent
is a legitimate shape — opt it out on the declaration line with
// partner-policy:allow=landing-widget-base-class.)
Why: /livewire/update remembers nothing
The first paint is made by the platform while it holds the tenant, the
install and the context. But every wire:click after it goes to
/livewire/update — a route that knows no landing pages and no
tenants; all it has is the snapshot the browser sent. So the base class
re-derives the full context on every hydrate from live data: real
tenant → the plan still includes widgets → the interactive surface is
on → the widget is still in the tenant's current catalog → declared
interactive → context minted through the intersections. #[Locked]
on the identity properties does real work but is not the guard: a
forged tenantUlid resolves against live data and is refused because
that tenant has not installed your app. Even the settings carried on
the snapshot are re-clamped to the widget's current schema before any
use.
mount(), boot(), dehydrate() and exception() are all final
on purpose. Your hook is mounted(), which runs once after the
context is up. And never store the context on a property — per-request
values are remade on every hydrate.
At render time your component receives — in addition to settings —
the identity triple moduleSlug / widgetKey / tenantUlid plus
widgetLocale, because it will rebuild the context from them on every
round trip. (The choice is made by inspecting the resolved class, not
the declared interaction field.)
Writes: withPublicWrite() and the three barriers
Every write on a visitor's behalf goes exclusively through:
public function submit(): void
{
$this->validate([...]);
$this->withPublicWrite('submit', function (): void {
app(\App\Services\PartnerApi\...\CreateSomething::class)(...);
});
}
In order, all of them in the base class so you cannot decline one:
- honeypot — a hidden field the platform owns (see the partial below). Filling it = a bot.
- throttle — per (IP × tenant × module × widget × action); the
default is 5/minute, or
maxPerMinuteper call.throttle()is also available to you standalone, to protect an expensive read. - captcha — only active when the platform has a provider bound and the tenant switched it on (or the platform forces it globally). The platform ships no provider by default, so the barrier is absent rather than blocking a tenant with a check that cannot run.
A refusal is not an exception you handle: the callback simply does
not run, withPublicWrite returns null, and $widgetBarrierMessage
carries a translated sentence the partial shows the visitor.
After all three pass — and only there — the public write ticket is
minted, and the context is re-derived with it so the write leg
enters; the callback runs; then a finally re-derives without the
ticket — the write clearance lasts exactly as long as the write,
and the following render runs with a read-only context. Nowhere else in
the platform mints the ticket, so there is no path to a public write
that skipped a barrier.
The form-guard partial — a mandatory include
@include('landing-builder::blocks.partner-widget.partials.form-guard')
Once, inside your component's root element. It renders the honeypot,
the guard marker and the refusal message. Enforcement comes from the
HTML actually rendered: the base class inspects its own render output
and derives the guard state from it — a form without the partial
arrives at the next write with a permanently failing honeypot barrier,
and there is no way around it. The misleading symptom when you forget
it: the form shows and appears to work, and every write returns null
with a barrier message.
The deployment trap: SESSION_DOMAIN — know it before you ship
An interactive widget = a POST with a session and CSRF, and the cookie only returns to the server if it covers the served host's domain:
SESSION_DOMAINunset: every tenant domain (custom or subdomain) gets its own session. This is the arrangement that works.SESSION_DOMAINpinned to the platform domain: a tenant serving its page fromexample.comgets no session cookie at all — the page renders, the form shows, and the first click is a 419 in front of a visitor who cannot report it.
The platform detects the second case rather than "fixing" it (a platform-wide cookie domain is usually set deliberately): it suppresses the interactive widget on that host and shows a polite card instead of a form that ends in 419, with a warning line in the okta-web logs. Not your bug and not your code's — a deployment precondition. Test on a custom domain if your tenants use them.
The only public_writable scope today
education.admission_applications.write — an admission application
submitted by a visitor on the tenant's own public site. A mailbox:
things go in and never come out — it has no .read counterpart at
all (a public read would expose other applicants' submissions to any
visitor), and it is marked is_dangerous, so the install-grant screen
paints it in danger colours on purpose.
Extending the list is a platform decision, never requested through the manifest, and governed by three non-negotiable rules: intake only (creating a new inbound record about the visitor themselves — no update, no touching an existing record), no people, no money, and never a delete. Have a use case? Bring it to the platform team.
Lifecycle
- Publish: you add
landing_widgetsto your version's manifest and publish it. Validation happens on acceptance (shapes, limits,integrationType=embedded,public_writesmarkings against the live catalog) — a non-conforming block refuses the version. - Install: the tenant installs your app from the store and grants scopes as usual.
- Appearing in the editor: the tenant's widget catalog is derived per request from its installations — installed apps' widgets only.
- Insert: the tenant admin drags the widget in, and the inspector
shows the settings form generated from
settings_schema. The preview renders with the admin's context. - Page publish: on the public site the widget renders with the
anonymous context (the union of the two intersections). A static
widget's output is cached for
cache_ttlseconds (the cache key carries a settings fingerprint — editing the settings and republishing shows immediately); an interactive widget renders live on every request. - Uninstall: the widget disappears from the editor catalog immediately. Blocks already placed on published pages lose their reference and fall into the same failure path: a silent void, no error on the tenant's site. (The tenant deletes the orphan block from its editor whenever it wants.)
- Version update: the catalog reflects the installed version's
manifest. A new settings field shows up in old blocks only through
your code's default (
?? $default); deleting a widget or changing itskeyorphans old blocks exactly like an uninstall.
Registering the alias and the component
// In your app's ServiceProvider::boot() — the flat alias form is mandatory.
use Livewire\Livewire;
Livewire::component(
'my-app-landing-register',
\Modules\MyApp\Livewire\Landing\RegisterWidget::class,
);
<?php
namespace Modules\MyApp\Livewire\Landing;
use Livewire\Component;
class RegisterWidget extends Component
{
/** settings_schema values for the served locale, as the editor stored them. */
public array $settings = [];
public function render()
{
// Reads exclusively via App\Services\PartnerApi\* — the service
// checks the scope internally and refuses what is not in the context.
$grades = app(\App\Services\PartnerApi\Education\Grades\ListGrades::class)(
onlyActive: true,
);
return view('my-app::livewire.landing.register-widget', [
'grades' => $grades->data,
'title' => $this->settings['title'] ?? 'سجّل الآن',
]);
}
}
That is all for a static widget. An interactive one extends
PublicWidgetComponent instead of Component, lives under
app/LandingWidgets/, and writes through withPublicWrite() as in its
section above.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
| Widget never renders | Alias contains :: |
Flat form in livewire_component and in Livewire::component() alike |
| "Just doesn't work" during development | Silent failure + 30s failure cache | Check the okta-web logs; do not use page refreshes as a test |
| Works in preview, empty on the public site | A scope not marked public_surface or not declared in scopes |
The preview runs with a full admin context; test with the poor context before publishing |
| Buttons unresponsive on the site | A static widget with wire:* in it |
Interactivity requires interaction=interactive and its full contract |
| Every write refused with a barrier message | Missing @include(... form-guard) |
Once, inside the root element |
| 419 on the first click on a custom domain | SESSION_DOMAIN pinned to the platform domain |
A deployment precondition — see the SESSION_DOMAIN trap |
| A setting value shows as literal text | All settings values are escaped at render |
Intended — never HTML through settings |
Pre-publish checklist
-
integrationType=embeddedand thelanding_widgetsblock ≤ 10 widgets. - Every
keymatches^[a-z][a-z0-9_]{1,39}$, unique within the app — and its name is final. -
livewire_componentin the flat form (no::), registered withLivewire::component()inboot(). -
scopesis the minimum the render actually needs — not a copy of the installation's scope list. - No people/finance scope called from the public render path — it will never be marked
public_surface. -
settings_schema≤ 20 fields, types from the eight, and everysettingsread behind?? $default. - A static widget has no
wire:*in its blade. - Interactive: extends
PublicWidgetComponent, underapp/LandingWidgets/, and every write insidewithPublicWrite(). - Interactive:
@include('landing-builder::blocks.partner-widget.partials.form-guard')once inside the root element. - All
public_writesare markedpublic_writablein the catalog andinteraction=interactive. - Interactive tested on a custom tenant domain if your tenants use them.
- The render tested with the poor context (no user,
scopesonly), in both page locales, and with an empty data state. -
preview.thumbnail_urlis a working https URL, and the card reviewed in the editor.