Add Micro-Frontend (Iframe Composition)
Composition mode. For micro-apps that are Next.js apps built and deployed by this project, add-microfrontend-zones (Multi-Zones, one origin, no iframe) is the default — field-tested 2–4× faster to first paint and without the auth bridge. Use this skill when a micro-app is not a Next.js app, is third-party or untrusted code, or must render inside a page of another app.
This skill owns the composition mechanism: the host component, the bridge protocol, auth and URL and scope syncing, sandbox flags, and CSP.
add-microapp owns domain boundaries, collection ownership, RBAC, and repo bootstrap. Load it first. Come back here to wire the apps together.
All apps share one DaaS backend and one Supabase project. Each micro-app is a standalone Next.js app with its own SSR, routing, and deployment. The browser composes them.
When to use iframes
Use this skill when at least two of these are true:
- Separate teams own separate parts of the product.
- Parts must deploy on their own schedule.
- A part must not be able to read the host DOM, CSS, or memory.
- A part already exists as a deployed app.
Do not use this skill when:
- One team owns everything. Use routes in a single app.
- The parts share layout, modals, or a scroll container. See the limits in url-and-history.
- You want code reuse. Iframes share nothing. Use a package.
- The section is a widget, not a page.
The cost is real. Iframe composition adds an auth bridge, a message contract, echo
suppression, history rules, dialog limits, and two CSP headers. Isolation is complete;
complexity is not low. add-microapp without iframes is often the better answer.
Critical Rules
- Micro-apps load in
<iframe>elements. The Main App owns layout, navigation, and the framesrc. - The Main App must not read the frame DOM. The micro-app must not read
window.parentDOM. All communication usespostMessage. - Copy
assets/shared/bridge-protocol.tsandassets/shared/mfe-cookies.tsto every app. Do not retype message shapes or cookie names. - Every message handler must check
event.origin, thenevent.source, then thesourceandvenvelope fields. - The iframe
srcmust depend on the micro-app origin and the route path only. It must never depend on the host query string. - The Main App owns token refresh.
SET_AUTHcarriesaccess_tokenandexpires_at. It must never carryrefresh_token. When the host session is inside the renewal window, the host must callrefreshSession()before answering —getSession()alone returns the same token and the frame asks again forever. - Each micro-app stores its own access token in its own cookie, on its own origin,
with
SameSite=None; Secure; Partitioned. The bridge is required on Amplify, on a custom domain, and in local development. - A micro-app validates the bridge token with
supabase.auth.getUser(token)on every request (viagetMfeUserinlib/bridge/mfe-middleware.ts). Never trust a local decode: onlygetUserobserves a sign-out that happened in the Main App. - Never overwrite a CLI-owned file. Any file carrying an
@buildpad-originheader belongs to the Buildpad CLI. It is restored bynpx buildpad upgradeand by anybuildpad add <library>— both rewrite owned files with no prompt, which is why every module install happens in Step 4a, before any merge. One documented exception:components/layout/navigation.tscarries the header but its own docblock says to edit it freely. The bridge merges into these files with the pinned edits in auth-bridge; it never replaces them. See "CLI-owned files" below. - The host sign-out control must
await logoutAllMicroapps()before it triggers/api/auth/logout. The host cannot delete a cookie on a micro-app origin. SET_AUTHmust carryresource_urion any project that usesmanage-scopeoradd-multitenancy. Without it every micro-app call resolves at root scope and returns 403. On a project that uses neither, no scope cookie exists and noX-Resource-Uriheader is expected — do not fabricate one.- Two token paths exist, and a module uses one of them. The CLI's ~16 Next proxy
routes read
lib/api/auth-headers.ts— pinned edit H1 covers those. A module whose hooks importlib/buildpad/services/api-request.tscalls DaaS directly from the browser and never touches H1 — pinned edit W1 is the only thing that authenticates it. Rungrep -rl "services/api-request" components libandgrep -rn "fetch('/api/" components libin the installed module, then apply both edits. The Users module is direct-call: H1 can be perfect while every users, roles, policies, and permissions call fails. - The sandbox attribute must omit
allow-modalsandallow-top-navigation. It must includeallow-popups,allow-downloads, andallow-storage-access-by-user-activation.allow-popupsis load-bearing for the Files module download, not only for OAuth — see security. - Micro-app pages must not call
window.confirm,window.alert, orwindow.prompt— including calls shipped by the CLI (audit for them in Step 4). Use MantineModalormodals.openConfirmModal. - A framed micro-app renders content only. The host owns all chrome, so the
frame must show no sidebar, no header, no login form, and no sign-out control —
double chrome also lets the user navigate the frame away from the section the
host thinks is open. Pinned edit E1 skips
AuthenticatedShellwhen the request arrives inside a frame; opened directly, the micro-app keeps its full shell. E1 must decide from the bridge cookie, not fromSec-Fetch-Destalone: an RSC fetch androuter.refresh()re-render the same layout on the server withSec-Fetch-Dest: empty, and a header-only test then mounts a second full shell. - All apps must use the same
NEXT_PUBLIC_BUILDPAD_DAAS_URL,NEXT_PUBLIC_SUPABASE_URL, andNEXT_PUBLIC_SUPABASE_ANON_KEY. NEXT_PUBLIC_HOST_ORIGIN/HOST_ORIGINare reserved by the CLI'slib/origin.tsand mean this app's own public origin. Never set them to another app's URL. The Main App's origin travels inNEXT_PUBLIC_MICROAPP_URL_MAIN, which bootstrap already writes into every.env.local. Localhost overrides go in.env.development.local, never.env.local—next buildloads.env.local, so a localhost value there is baked into the production CSP.- Verify field names with
mcp_daas_schemaormcp_daas_fieldsbefore you write anysort,fields, orfilterparameter. A wrong name returns a 500 that is hard to trace through the frame.
CLI-owned files
A Buildpad starter is not an empty Next.js app. npx @buildpad/cli bootstrap installs
components, auth routes, and middleware, and marks each file with an
@buildpad-origin header. npx buildpad upgrade restores them — interactively it
prompts on local changes, but with --yes or --strategy=overwrite (what a
non-interactive agent uses) it silently reverts them.
Before Step 3, run the preflight in every app:
grep -rl "@buildpad-origin" --include="*.ts" --include="*.tsx" app components lib middleware.ts 2>/dev/null
Files this skill touches, and how:
| Path | Owner | Action |
|---|---|---|
lib/supabase/middleware.ts |
CLI | Merge — pinned edits M1–M3 in auth-bridge |
middleware.ts (root) |
CLI | Do not touch — it sets Cache-Control: private, no-store, the only cache header on ~20 session routes |
app/api/auth/logout/route.ts |
CLI | Merge — pinned edit L1 (expire the three bridge cookies with framedCookieOptions(0, …), never delete()). It has a GET handler the shell navigates to; never drop it |
app/login/page.tsx |
CLI | Merge — pinned edit P1 (wrap the form in LoginBridge) |
components/DaaSProviderWrapper.tsx |
CLI | Merge — pinned edit W1 (useMfeToken as second token source and the readiness gate) |
components/ui/file-manager/*, components/ui/users-management/*, module hooks |
CLI | Do not touch — installed by buildpad add files-routes / add users-routes. Wrap them; never edit them. This is why search cannot be URL-synced (Step 4) |
components/layout/AuthenticatedShell.tsx |
CLI | Merge (host only) — pinned edit S1 (await logoutAllMicroapps()). Micro-apps leave it untouched: E1 keeps it out of the frame entirely |
app/(authenticated)/layout.tsx |
CLI | Merge (micro-app) — pinned edit E1 (skip the shell when framed; content only inside the frame) |
lib/api/auth-headers.ts |
CLI | Merge — pinned edit H1 (read mfe_access_token before the session) |
everything under lib/bridge/, components/Microapp*, components/LoginBridge.tsx, config/app-urls.ts, next.config.ts, the three bridge auth routes |
this skill | New files — copy from assets/ |
On every merged file, add one line under the CLI header:
// ⚠️ LOCAL MODIFICATION (add-microfrontend): re-apply pinned edits after buildpad upgrade.
Architecture
Main App (host) Micro-App A Micro-App B
┌───────────────────────────┐ ┌────────────┐ ┌────────────┐
│ AppShell: nav + layout │ │ own SSR │ │ own SSR │
│ ┌───────────────────────┐ │ │ own routes │ │ own routes │
│ │ MicroappIframe │◀┼───────▶│ own cookie │ │ own cookie │
│ │ postMessage bridge │ │ └─────┬──────┘ └─────┬──────┘
│ └───────────────────────┘ │ │ │
│ owns: session, refresh, │ │ │
│ scope, theme, navigation │ │ │
└─────────────┬─────────────┘ │ │
└─────────────────┬──────────┴──────────────────────┘
▼
┌───────────────────────┐
│ one DaaS backend │──▶ Supabase (Auth + DB)
└───────────────────────┘
Workflow
Step 0: Discover the project context (always first)
Call get_project_detail on the platform MCP server. Take every URL and credential
from the response. Never ask the user for them.
Tool naming in this repo: platform tools have no prefix (get_project_detail). DaaS
tools use the mcp_daas_* prefix (mcp_daas_schema, mcp_daas_cors-settings).
Read project.mainAmplifyUrl (host origin), project.daasUrl,
project.supabaseUrl, project.supabaseAnonKey, and microapps[] (each with name
and amplifyUrl). The response may carry more fields than the documented schema
(workers, messaging, …) — the schema lists what these skills consume, not
everything the platform returns. When you enumerate origins (CSP, CORS), enumerate
what is actually present.
Stop and report to the user if daasUrl, supabaseUrl, or mainAmplifyUrl is null.
Do not continue with a placeholder.
Full response schema: add-microapp context discovery.
Step 1: Write config/app-urls.ts in every app
This file is committed to git. Amplify builds it with no console configuration.
The local-dev override for the Main App origin is NEXT_PUBLIC_MICROAPP_URL_MAIN —
bootstrap already writes the deployed value into every .env.local (Rule 17; never
NEXT_PUBLIC_HOST_ORIGIN). Put localhost overrides in .env.development.local
only: next build loads .env.local, and a localhost value there ends up inside
the production CSP header.
Generation rules and the failure modes: add-microapp app-urls config.
Step 2: Install the bridge contract
Copy to lib/bridge/ in the Main App and in every micro-app, unchanged:
| Copy from | Copy to |
|---|---|
assets/shared/bridge-protocol.ts |
lib/bridge/bridge-protocol.ts |
assets/shared/mfe-cookies.ts |
lib/bridge/mfe-cookies.ts |
Step 3: Wire the host
New files (no collisions):
| Copy from | Copy to |
|---|---|
assets/host/useMicroappHost.ts |
lib/bridge/useMicroappHost.ts |
assets/host/MicroappIframe.tsx |
components/MicroappIframe.tsx |
Set the Main App login route in useMicroappHost.ts at the line marked AGENT.
Change nothing else.
Route mapping. microapps[] carries a name and an amplifyUrl — no route.
Four strings that look alike are independent; write the table before writing pages:
Platform name |
MICROAPP_URLS key |
Local directory | Host page file | path prop |
|---|---|---|---|---|
users-management |
'users-management' |
whatever the repo is called | app/(authenticated)/users/page.tsx |
that micro-app's DEFAULT_AUTHENTICATED_ROUTE |
The path prop must be a route that exists in that micro-app — read its
DEFAULT_AUTHENTICATED_ROUTE from its config/app-urls.ts, or verify a page file
exists at the path you choose. A wrong path does not error: it bounces through the
login bridge and looks like it works while every deep link is broken.
Host pages live in the authenticated route group — in a Buildpad starter that is
app/(authenticated)/, whose layout mounts DaaSProviderWrapper and the app shell.
A page outside it renders with no auth context and no chrome. The embedding page must
wrap the frame in Suspense: useMicroappHost reads useSearchParams(), and a
statically prerendered page without a boundary fails next build.
// app/(authenticated)/users/page.tsx
import { Suspense } from 'react';
import { Skeleton } from '@mantine/core';
import { MicroappIframe } from '@/components/MicroappIframe';
import { MICROAPP_URLS } from '@/config/app-urls';
export default function UsersSectionPage() {
return (
<Suspense fallback={<Skeleton height="100%" width="100%" />}>
<MicroappIframe
src={MICROAPP_URLS['users-management']}
path="/users"
title="Users Management"
// Derive this list from what the framed page really syncs (Step 4), never
// from a generic default. A name the micro-app never sends is dead weight;
// a name the micro-app sends and this list omits is dropped silently.
allowedParams={['user']}
// Inside Mantine AppShell.Main, subtract the header AND the shell padding.
height="calc(100vh - 60px - 2 * var(--mantine-spacing-lg))"
/>
</Suspense>
);
}
Navigation in a hand-written shell must be a client component
(<NavLink component={Link} …> inside 'use client'). The starter's
AuthenticatedShell handles nav itself — add the section items to it instead.
Sign-out (pinned edit S1). The starter's shell signs out with
window.location.href = '/api/auth/logout' — a GET navigation, not a POST fetch.
There is nothing to intercept after it runs, so the broadcast must come first, awaited:
// components/layout/AuthenticatedShell.tsx — the sign-out onClick (CLI-owned; merge) () => {
const { logoutAllMicroapps } = await import('@/lib/bridge/useMicroappHost');
await logoutAllMicroapps(); // broadcast LOGOUT + 300 ms drain
window.location.href = '/api/auth/logout'; // the CLI GET route, unchanged
}}
Call broadcastScope(uri) after a tenant switch (scope projects only).
Step 4: Wire each micro-app
4a. Install the domain module FIRST (before any file below)
Order is not optional. buildpad add <library> rewrites files it owns without a
prompt and without --overwrite: an observed buildpad add api-routes reverted six
already-merged files (E1, L1, P1, W1, H1, and api/auth/user), and
add users-routes silently replaced a local page. Install every module you need
before applying any pinned edit, or the CLI destroys your merges.
A micro-app whose domain matches a Buildpad module must scaffold that module instead of hand-writing a page:
| Domain | Skill | Command |
|---|---|---|
| files | add-files | npx @buildpad/cli@latest add files-routes |
| users, roles, policies | add-users | npx @buildpad/cli@latest add users-routes |
A hand-written placeholder page is acceptable ONLY when no module covers the domain.
CLI 1.11.1 installs group-aware — it writes app/(authenticated)/files/… directly and
records those paths in buildpad.json. Verify where the routes landed; move them under
app/(authenticated)/ only if the CLI did not, and never by hand-editing
buildpad.json. Run the module's own required proxy routes step only if those routes
are genuinely missing — bootstrap already installs a large api-routes set, and
re-running it is what reverts merges.
The cost of skipping the module is measured, not theoretical. A placeholder exercises
the bridge and nothing else. The module is what carries downloads, modals, permission
gates, and the CLI's own dialogs, and each behaves differently inside the frame: the
Files row-menu download runs through window.open to a signed cross-origin URL and
needs allow-popups as well as allow-downloads; a Mantine confirm dialog stops being
modal at the frame edge; the module mounts its data fetches before the bridge token
exists (W1b). Six field trials on placeholder pages passed every gate in this skill
while the real modules were broken.
4b. New files (no collisions)
| Copy from | Copy to |
|---|---|
assets/microapp/MicroappBridgeProvider.tsx |
components/MicroappBridgeProvider.tsx |
assets/microapp/LoginBridge.tsx |
components/LoginBridge.tsx |
assets/microapp/useQueryParamSync.ts |
hooks/useQueryParamSync.ts |
assets/microapp/useMfeToken.ts |
lib/bridge/useMfeToken.ts |
assets/microapp/mfe-middleware.ts |
lib/bridge/mfe-middleware.ts |
assets/microapp/set-session.route.ts |
app/api/auth/set-session/route.ts |
assets/microapp/token.route.ts |
app/api/auth/token/route.ts |
4c. Merges into CLI-owned files
Apply these only AFTER 4a. Exact hunks in auth-bridge, "Pinned edits":
- M1–M3
lib/supabase/middleware.ts— accept the bridge token as a second session source and gate/api/auth/token. Do not replace the file: it owns thepublicOrigin()redirect, the Supabase cookie refresh, and the route table. - L1
app/api/auth/logout/route.ts— expire the three bridge cookies insideperformLogout(), beforesignOut(), usingframedCookieOptions(0, …)— a baredelete()emits an unpartitioned Lax expiry that the cross-site frame rejects. Keep the GET handler and the OAuth SLO. - P1
app/login/page.tsx— wrap the CLI form inLoginBridge(withSuspense). - W1
components/DaaSProviderWrapper.tsx— adduseMfeToken()as the framed token source alongside the Supabase path, and gate the children on itsreadyflag. Both halves are required. Without the gate the module mounts and fires its first DaaS fetches with noAuthorizationheader, gets 401, and never retries — Users shows "Not authenticated" forever and Files shows an empty file list over a backend that holds files. - H1
lib/api/auth-headers.ts— readmfe_access_tokenfirst, fall back to the Supabase session (this single edit fixes all ~16 CLI proxy routes). H1 covers the Next proxy routes only. A direct-call module needs W1 (Rule 12). - E1
app/(authenticated)/layout.tsx— render content only when framed (Rule 15's implementation path): skipAuthenticatedShell, keepDaaSProviderWrapper. Standalone keeps the full shell. Decide from the bridge cookie;Sec-Fetch-Destalone fails on every RSC re-render. Without this, the frame shows a second sidebar, header, and profile menu inside the host's — and its nav lets the user move the frame to a different page than the host section.
4d. Then wire
Mount
MicroappBridgeProviderin the micro-app root layout, insideMantineProvider. A page that is not wrapped never reports that it loaded, and the host shows its error state.Add
DEFAULT_AUTHENTICATED_ROUTEtoconfig/app-urls.ts— the micro-app's first real route.Wire the URL sync. From buildpad-ui's URL-state release (PR #154), the list managers persist search/filter/sort/page (and Files'
folder) in the frame's URL by default viahistory.replaceState, andMicroappBridgeProvider'sOutboundUrlMirrorposts those writes to the host automatically — module search now syncs with zero per-module wiring. Two obligations remain:- Reconcile the host allowlist.
pickParams()filters both directions, so every parameter the module writes must be named in the host page'sallowedParamsor it is dropped silently:['search', 'role', 'status', 'sort', 'page', 'folder', 'user', 'file']covers the stock modules plus the row-click sync below. - Detail-record sync stays a wrapper concern: drive
user/filefromonUserClick/onFileClickin a new local file rendered by the CLI page (the CLI page itself is@buildpad-origin— a one-line merge).
On module versions before the URL-state release, module search is unsyncable — those managers hold
search/page/sortin privateuseStateinside@buildpad-originfiles with no controlled prop, and Rule 9 forbids editing them. Do not scrape the frame DOM to fake it; take the upstream update instead.- Reconcile the host allowlist.
Audit for native dialogs:
grep -rn 'window\.\(confirm\|alert\|prompt\)' app components lib --include='*.tsx'. The CLI'srich-text-markdown.tsxships awindow.promptthat is silently dead in the frame — replace the call or avoid that interface on framed pages (Rule 14).
Step 5: Create the CSP headers
A Buildpad starter ships no next.config.ts — create it. These are complete
files. Two traps: the snippet must be a whole module (not a bare headers() method),
and @/… aliases do not resolve from the Next config loader — the import must be
relative. Never bake localhost into a production header: gate local origins on
NODE_ENV.
// Main App: next.config.ts (new file)
import type { NextConfig } from 'next';
import { MICROAPP_URLS } from './config/app-urls';
const dev = process.env.NODE_ENV === 'development';
const microappOrigins = Object.values(MICROAPP_URLS).map((u) => new URL(u).origin);
const frameSrc = ["'self'", ...microappOrigins, ...(dev ? ['http://localhost:3001', 'http://localhost:3002'] : [])];
const nextConfig: NextConfig = {
async headers() {
return [{
source: '/:path*',
headers: [{
key: 'Content-Security-Policy',
// frame-ancestors 'none': the host holds the real Supabase session and
// must not be framable by anyone. frame-src limits what IT may embed —
// it does nothing to stop it being embedded.
value: `frame-ancestors 'none'; frame-src ${frameSrc.join(' ')}`,
}],
}];
},
};
export default nextConfig;
// Micro-app: next.config.ts (new file)
import type { NextConfig } from 'next';
import { HOST_ORIGIN } from './config/app-urls';
const dev = process.env.NODE_ENV === 'development';
const ancestors = ["'self'", HOST_ORIGIN, ...(dev ? ['http://localhost:3000'] : [])];
const nextConfig: NextConfig = {
async headers() {
return [{
source: '/:path*',
headers: [{ key: 'Content-Security-Policy', value: `frame-ancestors ${ancestors.join(' ')}` }],
}];
},
};
export default nextConfig;
Verify after pnpm build: the resolved origins — and no http://localhost — appear in
jq '.headers' .next/routes-manifest.json.
Step 6: Configure CORS (runnable)
The DaaS default (cors_origins: ["*"], cors_allow_credentials: false) blocks
every credentialed browser call: the Fetch spec discards a credentialed response
carrying Access-Control-Allow-Origin: *. Fix it with the DaaS MCP tool
mcp_daas_cors-settings (wired in .mcp.json; REST equivalent
PATCH /api/settings/cors):
{
"action": "update",
"cors_origins": ["<mainAmplifyUrl>", "<each microapps[].amplifyUrl>", "http://localhost:3000", "http://localhost:3001", "http://localhost:3002"],
"cors_allow_credentials": true,
"cors_allowed_headers": ["Content-Type", "Authorization", "Origin", "X-Requested-With", "Accept", "X-Resource-Uri"],
"cors_max_age": 0
}
Verify:
curl -si -X OPTIONS "<daasUrl>/api/items/anything" -H "Origin: <mainAmplifyUrl>" -H "Access-Control-Request-Method: GET" | grep -i access-control
The response must echo the origin (not *) and include
access-control-allow-credentials: true.
Step 7: Add the tests
The starters ship no test tooling. Install it, then copy the three assets:
pnpm add -D @playwright/test
pnpm exec playwright install chromium
| Copy from | Copy to (host app) |
|---|---|
assets/tests/playwright.config.ts |
playwright.config.ts |
assets/tests/auth.setup.ts |
tests/auth.setup.ts |
assets/tests/iframe-composition.spec.ts |
tests/iframe-composition.spec.ts |
Fill every value in the spec's AGENT block and the config's webServer array (one
entry per app — this composition needs three servers). Add playwright/.auth/ and
test-results/ to .gitignore. Run with real credentials:
TEST_EMAIL=... TEST_PASSWORD=... pnpm exec playwright test
Never add a host route to publicRoutes to make a test pass — auth.setup.ts +
storageState is the correct fix for tests landing on /login.
Step 8: Deploy (gated)
pnpm build must pass in every app you touched before any push — Amplify runs the
same build, and a failure surfaces minutes later in a file unrelated to your change.
Then push each micro-app first, then the Main App. No console environment variable
changes are needed: the URLs live in config/app-urls.ts.
After the first deploy, verify the handshake on the deployed origins, not on localhost — two localhost ports are the same site, so partitioned-cookie failures (Safari, Incognito) are invisible locally.
File Structure
main-app/
├── app/(authenticated)/{route}/page.tsx # one page per micro-app, Suspense-wrapped
├── components/MicroappIframe.tsx
├── components/layout/AuthenticatedShell.tsx # CLI-owned — pinned edit S1
├── config/app-urls.ts # committed
├── lib/bridge/{bridge-protocol,mfe-cookies,useMicroappHost}.ts
├── next.config.ts # NEW: frame-ancestors 'none' + frame-src
├── playwright.config.ts # NEW
└── tests/{auth.setup,iframe-composition.spec}.ts
{microapp}/
├── app/login/page.tsx # CLI-owned — pinned edit P1 (LoginBridge)
├── app/api/auth/logout/route.ts # CLI-owned — pinned edit L1
├── app/api/auth/{set-session,token}/route.ts # NEW
├── components/{MicroappBridgeProvider,LoginBridge}.tsx # NEW
├── app/(authenticated)/layout.tsx # CLI-owned — pinned edit E1 (no shell in frame)
├── components/DaaSProviderWrapper.tsx # CLI-owned — pinned edit W1
├── config/app-urls.ts # committed: HOST_ORIGIN + DEFAULT_AUTHENTICATED_ROUTE
├── hooks/useQueryParamSync.ts # NEW — and WIRED on the default route
├── lib/api/auth-headers.ts # CLI-owned — pinned edit H1
├── lib/bridge/{bridge-protocol,mfe-cookies,mfe-middleware,useMfeToken}.ts # NEW
├── lib/supabase/middleware.ts # CLI-owned — pinned edits M1–M3
├── middleware.ts # CLI-owned — untouched
└── next.config.ts # NEW: frame-ancestors
Before You Call It Done
Each item names its procedure — a box without evidence is not ticked.
- Every micro-app whose domain matches a Buildpad module renders that module, not a hand-written placeholder (Step 4, first bullet). A placeholder proves the bridge and nothing about the module.
- The frame shows real data. Record every DaaS response inside the frame and assert that none is 401 or 403. An empty list is not proof of an empty backend: the Files module swallows a 401 and renders "Drag files here" while files exist.
- The parameter the framed page syncs appears in the host
allowedParams, and driving it in the frame updates the host URL while the framesrcis unchanged (spec: in-frame state reaches the host URL). When the installed module exposes no controllable state, the gap is recorded and this box is marked N/A. - A signed-in user reaches a micro-app section with no login form and no extra click (spec: auth bridge signs the frame in).
- Inside the frame the micro-app shows no sidebar, header, or profile menu; opened directly on its own origin it still shows the full shell (E1). Check this after a fresh host page load and after a host tenant switch, not only after a client-side navigation — an RSC re-render is what defeats a header-only E1.
- Renewal works without waiting an hour: overwrite
mfe_expires_atto now+70 s and observe a secondset-sessionround trip (spec: token renewal). - Sign-out:
mfe_access_tokenis present on the frame origin before the click and absent after (spec: logout clears the micro-app cookie). - Scope projects only (Rule 11): a frame's
/api/items/*call carriesX-Resource-Uri. On other projects this box does not apply. -
jq '.headers' .next/routes-manifest.jsonshows the CSP with real origins and nohttp://localhostin a production build, in every app. - The Step 6
curlechoes the origin andaccess-control-allow-credentials: true. -
pnpm buildis green in every touched app. - The deployed handshake was verified on the real cross-site origins, including once in Safari or an Incognito window (partitioned cookies).
References
- Bridge protocol and sequences
- Auth bridge: pinned edits, refresh, scope, sign-out
- URL, history, layout limits
- Sandbox, CSP, cookies, message validation
- Troubleshooting
- add-microapp — domain boundaries and repo bootstrap
- daas-platform — DaaSProvider and CORS rules
- authentication-proxy — direct calls and proxy routes