feat(app-create): iframe-extension authoring — Iframe prompt choice, private-only rule [BEX-459] - #87
Open
piyushsarin-sib wants to merge 14 commits into
Open
feat(app-create): iframe-extension authoring — Iframe prompt choice, private-only rule [BEX-459]#87piyushsarin-sib wants to merge 14 commits into
piyushsarin-sib wants to merge 14 commits into
Conversation
This was referenced Sep 2, 2026
…nsions, private-only [BEX-281] The iframe-extension launch, CLI side: - The UI-app integration-type prompt offers Iframe (Embeds your page in a modal) next to Link, on a private app only — iframe extensions are private-only in v1. The choice is hidden (not disabled) on a public app: every layer refuses the combination. - The iframe branch asks for the embed URL and writes it to each entry's modal_iframe_url instead of redirect_link; the example context-URL lines in the created-app box read either destination. - Private-only enforced locally too: uiAppType.validateConfig refuses an iframeExtension block on a public app before any round trip (APP_UI_IFRAME_PRIVATE_ONLY), mirroring bo-be's 400. - Smoke: yarn smoke --suite=ui grows an iframe leg (create → upload no-op → install → uninstall → delete) that skips on a build without the choice or an environment without the registry flip; its new prompt pins are covered by en.test.ts like the existing ones. - Docs updated in the same PR (repo rule): agent-context/SKILL.md, agent-context/AGENTS.md, CLAUDE.md. One changeset (minor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and size seeding Rebase fallout from #92 (registry default_size seeding) and #97 (non-interactive actionLink UI creation) landing on main first: - resolveUiAppOrRedirectUris threads the distribution answer through to resolveUiApp, which gates the Iframe choice (private-only). The non-interactive path deliberately doesn't take it — that route is actionLink-only by design, so its buildSurfacePointList call pins urlField: 'redirect_link'. - buildSurfacePointList carries both #92's sizeFor and this branch's urlField/url; its unit tests updated to the merged signature. - Agent docs merged: the non-interactive flags (#97) and the Iframe choice documented side by side, both noting the non-interactive routes stay actionLink-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
piyushsarin-sib
force-pushed
the
feat/iframe-extension-authoring
branch
from
September 3, 2026 07:38
5b957d8 to
7c31953
Compare
An iframeExtension entry on a widget slot can author layout: "inline" (the page embedded directly in the card body) vs "modal" (the default — never written, so layout-less configs stay byte-identical). - create asks the question only for an iframe WIDGET placement (an action slot's menu entry is a modal by definition) and writes the key only for inline. - validateUiApp refuses layout on actionLink entries and pins the vocabulary; the widget-only rule stays server-side (registry fact). - fields.ts gains the layout: row, so the upload diff, created-app box and install summary all show it. - Docs (SKILL/AGENTS) + changeset extended; 1437 tests green. Requires bo-be with per-entry layout support (same-branch PR #404) — an older server 400s the key as unknown, the standard sequencing rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…[BEX-467] bo-be stamps a root `sandbox` onto the stored snapshot — the iframe policy the platform decides, not the partner. Left in the wire-only list's blind spot, the first successful upload writes it into app-config.json and every upload after that reports drift on a field nobody can edit. `stripUiAppWireOnlyKeys` is the single reader and strips at every depth, so naming the key in `uiAppType.wireOnlyKeys` is the whole change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…[BEX-459] Four rules that finish per-entry iframe authoring, all of them about a value the CLI would otherwise write and something downstream would then ignore or reject. `modal_size` — `small` / `medium` / `large`, asked for every iframe entry that actually OPENS a modal. That is deliberately a different set of placements from the layout question's: layout is widget-only (an action slot's menu entry has one presentation), while the menu entry opens a modal too, and a widget card answered `inline` opens none. Large is the default, is pre-selected rather than listed first, and writes nothing — so a default answer leaves app-config.json byte-identical to one authored before the question existed, the same contract `layout: "modal"` has. `validateUiApp` pins the vocabulary and refuses the field on an `actionLink`; the `modal size:` row is one more `VALUE_ROWS` entry, so the upload diff, the created-app box and `app install`'s summary all gain it at once. `buildSurfacePointList` now refuses a `layout` on a row that renders no card, naming the entry the way the validator does. The prompt never asks for one there, so this cannot fire from the interactive flow — but the builder is what stamps the field and it stamps whatever row it is handed, so an unchecked caller would author a block the upload endpoint rejects and the partner would meet the rule one round trip later, phrased by the server. `--ui-config` refuses `layout` and `modal_size` by name instead of dropping them. The file's key set is fixed and everything else is discarded, so a silently ignored presentation field means the created app renders differently from the file that asked for it, with nothing said anywhere. `app upload` translates the platform's own layout refusal — narrowly, exactly like `isPublicDistributionRefusal`: only a 400 that mentions `layout` is relabelled, so an unrelated 400 keeps the server's text. No local slot list: whether a slot renders a card is a registry fact and the CLI holds no copy of the registry, so the server stays the authority and this only says what to edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ui suite's iframe leg went URL -> output directory. Two questions can render in between and neither is guaranteed: the layout list appears only when the first iframe-enabled registry row on the picked page is a widget, and the modal-size list only when a modal actually opens. Which row the registry serves first is not this suite's business, so the leg cannot know in advance, and either question is also missing from an older build under --against=published. The pty runner gains `optional` on an exchange: a prompt that hasn't appeared is skipped once a LATER one has — proof the flow moved past it, rather than that it is merely slow — and trailing optionals left unmatched at exit are not a broken flow. Distinct from the existing `send: () => null` abort, which says the prompt rendered without the choice this leg needs and skips the whole leg. Both new questions are answered with their default (Enter), and the leg now asserts neither wrote a key — the only part of the omit-the-default contract a real run can prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y [BEX-459] Both agent docs gain `modal_size` next to `layout`, and say plainly that the two are gated differently — an agent that assumes they cover the same placements will author a size on an inline card, or omit one on a menu entry. Also: `--ui-config` refuses either field by name, and `sandbox` joins the server-stamped keys that must never be written into app-config.json. Appended to the branch's existing changeset rather than adding a second file; the bump level stays minor, since this is all new surface on an unreleased feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the branch up to date with main through 2.3.0 (#100 Brevo Functions, #104 BEX-470 snake_case app-config.json keys, #106 BEX-471 --json snake_case twins, #107 release). Conflicts resolved by keeping both sides: - src/lang/en.ts — main's APP_UPLOAD_APP_TYPE_MISMATCH plus the branch's APP_UPLOAD_UI_LAYOUT_REJECTED; APP_UPLOAD_UI_APP_AUTH_HAS_OAUTH_FIELDS takes main's snake_case `redirect_uris` wording. - src/__tests__/commands/app/upload.test.ts — import union; both the app_type-agreement (BEX-468) and layout-400 / diff-row suites kept. - agent-context/SKILL.md, agent-context/AGENTS.md — main's app_type, --json-twin and `brevo function` reference merged with the branch's iframe / layout / modal_size / sandbox reference. Both files describe three app types and stay in sync with each other. Semantic (non-marker) collisions verified after the auto-merge: - resolveUiAppOrRedirectUris (create.ts) carries BOTH main's `appType` and the branch's trailing `distribution`; the `function` early-return precedes resolveUiApp(distribution), and the single call site passes both. - scripts/smoke/ui-app.ts — the branch's restructured createUiSmokeApp picked up main's snake_case reads (configField 'app_id' / 'app_name', configRedirectUris) cleanly; the new iframe must() blocks only read per-entry ui_app keys, which were already snake_case. - UI_APP_WIRE_ONLY_KEYS keeps 'sandbox', and capabilities.test.ts asserts wireOnlyKeys for oauth, ui and function. No branch code reads an app-config.json key in camelCase, and the branch adds no new --json output keys (the snake_case twinning in withSnakeCaseAliases is generic and applies to any new top-level key). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ness [BEX-467] The `sandbox` bullet above `UI_APP_WIRE_ONLY_KEYS` justified keeping the key out of `app-config.json` partly on the value being inert — "a value the partner can edit and the platform ignores". That half expires: the plan is for the extensibility UI kit to read the sandbox from the manifest in a later phase, at which point the platform reads the stored value rather than ignoring it. Rewrite the bullet (and the matching comment on the strip's test) to rest on the durable reason, which was already stated first: the sandbox is server-derived policy the partner may not author, and bo-be 400s an authored one. Note the planned manifest read in a clause, since it makes the not-authorable rule matter more rather than less. Comments only. `UI_APP_WIRE_ONLY_KEYS`, the strip, validation and every test assertion are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A product decision applied across all four repos in the chain at once. The
old name became a misnomer once `layout: "inline"` existed — an inline card
embeds the page directly and opens no modal — so the field is named for what
it holds rather than how it happens to be presented. The manifest and UI kit
spelling is `iframeHref`; the authoring spelling here and on the bo-be wire
is `iframe_href`.
No migration and no alias, deliberately: the registry flip that would let an
`iframeExtension` app exist has not been applied on staging or production, so
no `app-config.json` in the wild can carry the old key. In particular the key
is NOT added to `LEGACY_KEY_ALIASES` in `src/lib/config.ts` — those tables
exist for the BEX-470 camelCase→snake_case migration of files that really do
have a population.
Surface: the `SurfacePointEntry` field, the create prompt and
`buildSurfacePointList`'s `urlField`, the validator's root-migration hint and
both per-type refusals, the message key
(`APP_CREATE_UI_MODAL_IFRAME_URL_PROMPT` → `APP_CREATE_UI_IFRAME_HREF_PROMPT`),
the scaffold templates, the agent docs and the smoke assertions.
Two things followed the field rather than being left behind:
- The upload diff's row is now `iframe URL:` instead of `modal URL:`, named
for the field for the same reason the field is.
- The two refusal messages that asserted the URL always opens in a modal are
now layout-neutral. They still refuse exactly what they refused before.
The user-visible prompt copy is unchanged, so the smoke's prompt patterns and
their `en.test.ts` pins still match.
`CHANGELOG.md` keeps the old name in the released 2.3.0 entry — that is
history, not a live reference.
Also converts `scaffold.test.ts`'s two bare `stdin.isTTY = …` assignments to
the descriptor form the same file already uses. Most suites define the
property with `{ value, configurable: true }` and no `writable`, which
defaults to non-writable; Jest reuses a worker across files, so an assignment
throws once one of those has run in the same worker. It passed only by
scheduling luck, and this rename's byte-size changes reshuffled the schedule
enough to surface it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st [BEX-467] The `sandbox` bullet above `UI_APP_WIRE_ONLY_KEYS` described the manifest read as a later phase. That phase has landed — app-store-backend is serving the stored `sandbox` and the UI kit is rendering from it rather than the fixed constant it used to — so this is a tense correction, nothing more. No CLI behaviour changes: `sandbox` stays in `UI_APP_WIRE_ONLY_KEYS`, stays unauthorable, and stays out of `app-config.json`. The durable reason is unchanged and deliberately left intact — it is server-derived policy a partner may not author, and bo-be 400s an authored one. The "don't re-justify this by who *reads* the value" warning is kept, and is now more relevant rather than less: a real reader makes it tempting to explain the strip by the read, which would be the wrong reason for the right rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`parseUiConfigFile` reads a fixed key set, so anything else in the file vanished with no error. `UI_CONFIG_UNSUPPORTED_KEYS` existed to close exactly that gap for `layout` and `modal_size`, but `iframe_href` — the destination itself — was not on it: a file asking for an embedded page created an actionLink app pointing somewhere else, silently. Two classes now, two messages, because they are wrong for two different reasons and one sentence cannot honestly cover both: - iframe-only (`iframe_href`, `layout`, `modal_size`) — authorable, just not on this actionLink-only route, so the message says to create the app interactively or put the field in app-config.json and upload. The same three `validateUiApp` refuses on an actionLink entry. - platform-owned (`link_target`, `sandbox`, `extension_point_name`, `version`) — not authorable anywhere, so the message says to remove the key. Derived from `uiAppType.wireOnlyKeys` rather than re-listed, so it cannot drift from the keys the CLI strips off the echo; a fifth stamped key is refused for free. `sandbox` is the one with no prior local refusal at all: `validateUiApp` checks named fields and has no unknown-key sweep, so an authored one reached the wire and came back a 400. Naming it before any registry read beats a server error for a value the partner should never have written. Both loops run ahead of every network call, like the other non-interactive guards. SKILL.md and AGENTS.md updated together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`stripUiAppWireOnlyKeys` covers the inbound direction only: it runs on what the server echoes. `app upload` sends the file's `ui_app` block verbatim (plus the injected `link_target`), so a `sandbox` hand-added to app-config.json travelled outbound unexamined and came back an opaque bo-be 400. Tolerable for the other three wire-only keys — `version` and `extension_point_name` are inert, `link_target` is pinned per entry — but not for this one. `sandbox` is the embedded frame's whole permission set, and nothing local holds a copy of it any more: bo-be stamps it at write time, app-store-backend serves the stored value on the manifest, and the kit applies it verbatim with no default of its own (absent ⇒ the fail-closed `sandbox=""`). An authored value is therefore not a preference weighed against the platform's — it is a partner writing the attributes their own frame runs under. `validateUiApp` now refuses it by name at the block root and on every `surface_point_list` entry, the two depths the strip recurses through, ahead of the per-entry checks so a blank label on entry one cannot decide whether the security-relevant problem is the message the partner sees. Refused rather than stripped on the way out, deliberately: dropping the key silently would leave a partner believing their value is in force. That makes the pair symmetric — `--ui-config` already refuses it off the same `wireOnlyKeys` list on the non-interactive route. The scaffolded AGENTS.md / CLAUDE.md / README.md say so too, and gain the `layout` and `modal_size` rows they were missing — both authorable, both undocumented in the copies a partner actually reads. The `iframeExtension` line there no longer claims the page is always in a modal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dtsl/extensibility-ui-kit 0.11.0 is released and deleted EXTENSIBILITY_IFRAME_SANDBOX outright. The kit now applies the served app_configs.sandbox verbatim and has no default behind it: an entry served without one renders sandbox="" — an inert frame — plus a [extensibility:missing-iframe-sandbox] dev warning. No CLI behaviour changes. `sandbox` stays in UI_APP_WIRE_ONLY_KEYS, stays unauthorable, stays out of app-config.json and stays refused by --ui-config. Only the explanation was wrong. The `sandbox` bullet above UI_APP_WIRE_ONLY_KEYS said the kit "renders from" the manifest "rather than the fixed constant it used to", which still left a fallback implied. It now says the stamp is load-bearing: the platform stamps the policy, the manifest serves it, the kit applies it verbatim with no default, so this is not merely a field the partner may not author but one whose absence breaks the app. The durable reason (server-derived policy; bo-be refuses an authored one) and the "don't re-justify this by who reads the value" warning are both kept — the warning is now more relevant, not less. Also corrected the strip test's comment, which still described the manifest read as "planned ... in a later phase", and dropped the "stored snapshot's root" wording in both places: the kit serves the policy per entry, and the strip recurses at every depth regardless. SKILL.md and AGENTS.md said the kit "applies the stored value verbatim" without the no-default consequence; both now carry it, in matching wording. Neither mentions modal_iframe_url anywhere — checked, since the kit removed modalIframeUrl rather than aliasing it and this branch already renamed the authoring key to iframe_href. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Jira: BEX-459 · Epic: BEX-211
What
Phase 1 (authoring release) of the iframe-extension launch, CLI side. The validator, the upload diff (
modal URL:row), the wire-only strip and the write-back already spokeiframeExtension— only the prompt withheld it (removed in63bc9eewhile authoring wasn't ready).modal_iframe_urlinstead ofredirect_link. Nosizeprompt (decided): the modal's dimensions are hand-authored per entry inapp-config.json.uiAppType.validateConfigrefuses a hand-authorediframeExtensionblock on a public app before any round trip, with the same wording as bo-be's 400. (readProjectConfigdefaults an absentdistribution_typetoprivate, so only an explicitpublicreaches it.)yarn smoke --suite=uigrows an iframe leg — create (pty) → upload no-op → install → uninstall → delete — that skips rather than fails on a build whose prompt has no Iframe choice or an environment whose registry has no slot enabled foriframeExtensionyet (pre-flip). New prompt patterns are pinned againstmessagesinen.test.tslike the existing ones (the PR fix: harmonize app-create prompt copy (distribution & UI-app fields) #73 pty-timeout precedent).agent-context/SKILL.md,agent-context/AGENTS.md,CLAUDE.md. One changeset,minor.Tests
modal_iframe_url(neverredirect_link/link_target) and narrows both registry reads byiframeExtension; iframe-specific URL prompt copy.scripts/tsconfig.json.Sequencing
The staging registry flip lands before this releases (bo-be PR DTSL/app-store-bo-be#404 carries the SQL): on an un-flipped environment the restored choice dead-ends in
APP_CREATE_UI_POINTS_NONE_FOR_TYPE, which reads as a CLI bug and isn't. The prod flip follows this CLI release.Part of the iframe-extension Phase 1 (epic BEX-281). Counterparts: bo-be DTSL/app-store-bo-be#404 (registry flip + server-side private-only rule); kit PR (sandbox + modal size) to follow.
🤖 Generated with Claude Code
Update 2026-09-03 — rebased onto main (#92 + #97)
Main gained the registry default_size seeding (#92, the cherry-picked successor of #91, released in 2.2.1) and the non-interactive actionLink UI creation (#97, released in 2.2.2). Reconciliation on this branch:
resolveUiAppOrRedirectUris(from feat(app-create): non-interactive actionLink UI app creation #97) now threads thedistributionanswer through toresolveUiApp, which gates the Iframe choice (private-only). The non-interactive path deliberately doesn't take it —--ui-app/--ui-configstayactionLink-only by design, so that route'sbuildSurfacePointListcall pinsurlField: 'redirect_link'.buildSurfacePointListcarries both feat(app-create): seed placement size from the slot's registry default [BEX-461] #92'ssizeForand this branch'surlField/url; its unit tests updated to the merged signature. An iframe entry therefore also picks up a slot's registry default size at create — the one-knob-two-surfaces note from feat(app-create): seed placement size from the slot's registry default [BEX-461] #91/feat(app-create): seed placement size from the slot's registry default [BEX-461] #92 applies (a seeded height also drives the modal body).actionLink-only.1434 tests green, lint + prettier clean, smoke scripts typecheck.
Update 2026-09-03 (2) — inline iframe cards
An
iframeExtensionentry on a widget slot can now authorlayout:"inline"(page embedded directly in the card body) vs"modal"(the default — never written, so layout-less configs are byte-identical).app createasks the question only for iframe widget placements;validateUiApprefuses the field onactionLinkentries; the diff/box/install summary gain alayout:row viaVALUE_ROWS. Counterparts: bo-be #404 (validation + storage, widget-only rule from the registry), app-store-backend #757 (servesappConfigs.layout,cacheSchemaVersionv9→v10), kit #317 (inline routing). Deploy order: bo-be → backend → kit → CLI.Update 2026-09-04 —
layoutis widget-only, and what still lands in this PRRule.
layout: "inline"is only meaningful on the 6 widget slots (contactDetails|dealDetails|companyDetails×overviewMain.widget|overviewSidebar.widget), the registry rows whosecomponent_typeiswidgetand therefore render a card. The 3 header-menu slots (*.headerMenu.action,component_type: "action") yield a menu entry that must open something, so there is nothing to be inline in. The slot kind is judged from the registry row, never from a local list (CLAUDE.md: no registry mirror in the CLI).What this PR does today
promptIframeLayout(src/app-types/ui/authoring.ts) asks How should it appear on the page? only when the picked placement's row hascomponent_type === 'widget'. A header-menu placement never sees the question and its entry is written withoutlayout.buildSurfacePointListcan only ever writeinline;modalis the default and is never stamped.validateUiApppins the vocabulary ("<value>" is not supported — use "modal" or "inline") and refuseslayouton anyactionLinkentry. It deliberately has no local slot-kind check; the platform'svalidateLayoutPerEntryanswers that on create and upload with:400 ui_app.surface_point_list authors layout "inline" on slot(s) that render no card: <slug> (component_type "action")--ui-app/--ui-configstayactionLink-only;layoutis not part of their input.Landing before merge (same PR)
layoutfor a row whosecomponent_typeis notwidget, throw locally naming the entry (ui_app.surface_point_list["<slug>"].layout: "inline" needs a widget slot; <slug> renders a menu entry). Same outcome as the skip today, but a future multi-select or a newcomponent_typevalue can no longer author inline silently.en.tsmessage sobrevo app uploadof a hand-editedinlineon a header-menu slot reads like every other per-entry refusal.actionLinkrefusal (none exist yet),en.test.tspins for the three layout prompt strings, and a create test that the question is absent for a header-menu placement (exists) plus one that a widget placement gets it.--ui-configrefuses alayoutkey explicitly instead of dropping it.Planned in this PR: modal size presets. For an iframe entry that opens a modal (widget slot with modal layout, or any header-menu slot)
app createasks Modal size — Small / Medium / Large, default Large. Large is the platform's current behaviour (the kit'sModalDialogis hard-codedsize="lg"), so an unanswered or Large answer writes nothing and layout-less configs stay byte-identical. Wire: per-entrymodal_size("small" | "medium" | "large",iframeExtensiononly, refused onactionLink), validated by bo-be, served asappConfigs.modalSizeinside the same v9→v10 cache bump aslayout, mapped by the kit onto the dialog size. This is a preset for the dialog, distinct from the widget card'ssize, which is still seeded from the slot's registry default (BEX-461) and hand-edited in px/%; the CLI still asks no px/% question. Counterparts to follow in bo-be #404, backend #757, kit #317. Tracked under BEX-211 / BEX-467.Sandbox stored in the snapshot, never authorable (2026-09-04). bo-be now stamps
sandboxinto the storedui_appsnapshot at the root with the defaultallow-scripts allow-same-origin allow-forms allow-popups, server-side only. The CLI API neither accepts it (an authoredsandboxinapp-config.jsonis a 400unknown key) nor echoes it. This PR addssandboxtouiAppType.wireOnlyKeysso a value can never reachapp-config.jsoneven if it were echoed, exactly likelink_targetandextension_point_name. In M1 the value is store-only: the manifest does not serve it and the kit keeps its constant.Update 2026-09-04 (3) — plan sync
ui.showConfirmis no longer dropped from v1; it moved to Phase 2c (host confirm dialogs for inline cards, M2c 2026-11-13). No change to this PR.app-config.json(Phase 1, sibling change).createwill write an app-type field (OAuth vs UI) so the file states what it is instead of relying on the presence ofui_app;uploadchecks it agrees with theui_app/authblock and refuses a mismatch with a migration hint;isUiAppConfig()stays the single reader; files without the field keep working. Open: wire (bo-be reflected key sets + openapi) vs file-only key stripped before upload. Lands in its own PR on top of this branch;CLAUDE.md's "there is no appType key" note changes with it.Plan of record: https://app.notion.com/p/sendinblue/Iframe-Embed-timelines-3d1449002dcb80fc8c3ff1a6a0c7c0b0