feat: DFX App 2.0 preview at /app2#1162
Conversation
Ships the new standalone single-file DFX app under public/app2/, served from the same $web static site at /app2/index.html on dev and prod. - Coexists with the current React app; no changes to src/, build config, or the SPA-fallback routing. Entry is the explicit /app2/index.html. - All asset paths are document-relative and the service worker is scoped to root only, so the app runs unchanged under the subpath. - Adds an app2 upload step to dev.yml and prd.yml (mirrors the widget upload); the entry HTML is uploaded no-cache so redeploys are fresh.
- Fix DOM XSS (js/xss-through-dom): the image-fallback handler no longer assigns a DOM attribute value to img.src; the mono placeholder is rebuilt via mono(), whose encodeURIComponent neutralises any injected markup. - Fix prototype pollution (js/prototype-polluting-assignment x2): the wallet switcher lookup uses a null-prototype object, so URL-seeded addresses such as __proto__ can no longer reach Object.prototype. Verified with CodeQL 2.25.6 (javascript-code-scanning): 0 alerts. - Add SIL OFL 1.1 license for the self-hosted Inter fonts and a THIRD-PARTY-NOTICES.md for the vendored libraries. - Deploy the preview on dev only (remove the app2 upload from prd.yml).
Model which network the CONNECTED wallet can actually settle on (Lightning- aware) instead of treating every session chain as an interchangeable receive target: - Lightning is offered only for a Lightning identity (LNURL/LNNID) or a pure- Lightning session — never for an on-chain SegWit (bc1) address, which is paired with Lightning in the JWT but cannot receive on it. Fixes the BTC buy defaulting to / offering the Lightning network. - Swap now validates the receive chain (chain2) too, prompting a wallet connect instead of letting the output land on an unreachable chain. - Mode switches and defaults pick a wallet-reachable chain, not the first one. - Asset-picker filter chips are derived from the reachable pool (no dead-end 'no assets found' on single-chain wallets) with an 'All' chip and a helpful 'not available with your wallet' hint; sell fee never mislabels a crypto amount with a fiat symbol; corrected the 'we flag the cheapest' copy and the scary pre-filled sell/swap amount. CodeQL javascript-code-scanning: 0 alerts. Offline suite: 144 passed.
New DFX users auto-register on connect, but the backend blocks trading until a
referral is submitted (RecommendationRequired). Instead of linking out to
app.dfx.swiss, the confirm-sheet gate now:
- shows an 'Invitation needed' state explaining a referral is required,
- takes the invitation/referral code OR email inline and submits it to the KYC
Recommendation step (PUT the step session URL with { key }),
- falls back to the full KYC flow when prerequisite steps come first,
- maps 404/400 to clear 'no matching customer' / 'invalid code' messages,
- tells the user the referral must still be confirmed by whoever invited them.
The KYC Recommendation step form gains the same lead + confirmation note.
CodeQL javascript-code-scanning: 0 alerts. Offline suite: 146 passed.
… account - The drawer's OpenCryptoPay entry used a generic cube glyph; it now renders the actual OCP brand mark (buildMenu gains full-SVG icon support). - Account 'Display currency' was hard-wired to EUR; it now reflects the selected fiat (updated in renderAccount and on currency change). CodeQL: 0 alerts. Offline suite: 145 passed.
Replace the muddy squiggle glyph on the account Display-currency row with the universal currency sign — a clean circle with four rays — that reads clearly at icon size and matches the line-icon set.
…more blurry flag) Replace the low-res flag png in the buy/sell/swap fiat pill and the currency picker with a sharp SVG coin showing the currency symbol (EUR €, CHF Fr, USD $, GBP £), matching the round crypto coins. Language picker keeps its flags.
Bring back the flags (EUR/CHF/USD/GBP) but render them as inline SVG instead of <img src>: an img rasterises the SVG at CSS-px size and looks blurry on retina, while inline SVG renders at device resolution and stays crisp. Each flag uses a unique mask id so multiple inline flags don't collide on a shared '#a'. Currency symbol coin remains the fallback for currencies without a bundled flag.
…n fallback Harden shownChainsFor/supportedChainOf so a Taproot/on-chain BTC address never sees Lightning as a network even when the session's blockchain list is empty or unexpected (the previous fallback returned all of the asset's chains).
…nal-IBAN overclaim The pay box hid the reference and claimed 'This is your personal IBAN' whenever the API flagged isPersonalIban — misleading (most users have no personal IBAN) and money-critical: if a shared/collection IBAN ever carried isPersonalIban the required reference would be hidden and the transfer stranded. Now the reference is shown whenever the API returns a remittanceInfo (the authoritative signal), the label stays a neutral 'IBAN', and a no-reference IBAN just says 'no payment reference needed' without claiming it's personal. Added a money-safety test.
…es=) When the embedding wallet reports balances via the ?balances=amount@asset contract (DFX's own primary balance source), the sell / swap-source picker now lists ONLY the assets actually held, sorted by balance, with the amount shown per row. Without balance data (e.g. a bare hardware-wallet connect, no contract addresses available client-side) it falls back to all sellable assets.
The API's Swiss QR-bill SVG was inlined via innerHTML into a fixed 158x158 box that forced a non-square aspect (distorted modules) and was too small for a dense Swiss QR to scan. Now it renders as a data-URI <img> at 220px with object-fit:contain (never distorted), matching the production app; GiroCode text still gets encoded locally. The QR content itself is API-provided.
…i18n keys - The account Display-currency icon was a static € even when the selected fiat was CHF/USD; it now follows the currency (€/Fr/$/£), matching the value. - Remove the now-unused yourIban / personalIbanNote strings left over from the remittanceInfo-driven IBAN rework.
…ache CSP breakage On localhost the app no longer registers the SW and actively unregisters any leftover worker + clears caches. A stale cached index.html served by an old SW had a CSP hash that no longer matched the inline script, so the browser blocked ALL app JS — connect-wallet and every button went dead. Root cause removed for local dev; prod/root still gets the SW.
- The asset-picker 'not available with your wallet' message wrongly appeared when a category chip simply filtered out a reachable asset during search (e.g. Bitcoin chip + 'ETH'); it now shows plain 'no results' and only claims wallet-incompatibility when the asset is genuinely unreachable. - Escape the currency symbol in fiatGlyph, matching curSymSvg.
…verflow the rounded corners .panels had border-radius but no overflow:hidden, so the darker You-receive panel's square background poked past the rounded bottom corners. Added overflow:hidden (fab is centered, box-shadow is unaffected).
Review notesScope check: the diff outside Findings
What looks solid: isolation from the existing app is real (verified no One architectural question worth settling before this grows further: the app doesn't reuse any of the existing codebase's wallet-connect, chain-config, or KYC-flow logic — it's a from-scratch reimplementation, which is how it ends up re-solving (and re-introducing bugs in) problems the existing app already fixed. That's a reasonable tradeoff for a short-lived stakeholder-preview spike, but the PR description's own "ahead of any root cutover" framing suggests this might be heading toward production. Worth confirming the intended lifecycle now — disposable preview vs. seed of the real rewrite — since that changes how much investment (tests, shared logic, maintainability) makes sense here. |
Architecture suggestion — keep the isolation, gain reusabilityFollowing up on my review notes: the standalone approach makes sense for a fast stakeholder preview, but if there's any chance this graduates ("ahead of any root cutover"), the current shape — the full app hand-written in one inline Two observations from the repo:
Proposal: build the new app as a fourth build target
What this buys:
An optional step further would be giving the new entry its own Vite config (two devDependencies) instead of a third instance of the index-swap build hack — also a low-stakes pilot for eventually leaving CRA/react-app-rewired, which is deprecated upstream. But the zero-new-tooling variant above already delivers the main value. Naming: reconsider
|
…, own PWA identity, branded 404, hero spacing
|
@Danswar Thanks for the review — both the findings and the architecture proposal were on point. Rather than argue, I built it: your fourth-build-target proposal is now implemented on What it is — exactly the shape you sketched:
Your findings, addressed by construction or directly:
Login slice so far: MetaMask + WalletConnect v2 + email magic-link ( Open question where I'd like your take: keep shipping under |
|
Keep |
Danswar
left a comment
There was a problem hiding this comment.
Review round 2 — React build target (3e398fc4, 18b2c291)
The architecture is what was proposed, and a lot of it verifies clean (credit list below). But the PR currently contradicts its own description on the most important point, and the money path has two flow-bricking bugs plus a cluster of inverted API semantics — several of them regressions of bugs the static preview's own commit history already fixed once. Requesting changes.
The structural problem: both apps ship, and only the old one deploys
build-app2.shoutputs toapp2-dist/(gitignored) — no workflow builds or uploads it.dev.ymluploads./buildwith--pattern "app2/*", which is CRA's verbatim copy ofpublic/app2/— the static preview. Through CI,/app2/still serves the vanilla app; none of the React work reaches users.public/app2/is fully intact at head (94 files, 4.9 MB, including all ofassets/vendor/— 3.3 MB). "assets/vendor/* is gone" doesn't hold: the PR contains zero deletions.- Consequently round-1 findings 2 and 3 are not moot — the hand-maintained CSP hash and the wasm cache-order clobber (
dev.yml:117uploads*.wasmimmutable,:135re-uploadsapp2/assets/vendor/*.wasmat max-age=3600) are both still live in what deploys. - The React artifact itself (built locally — CI never has): 8.2 MB, with the full 5.2 MB static tree embedded at
app2-dist/app2/(CRA copiespublic/wholesale intoBUILD_PATH), plus the main app'sfavicon.ico/logo.png/manifest.json. And it ships no CSP at all — the strict policy exists only in the staticindex.html. On the CSP axis the React artifact is currently a regression, not a fix. - Round-1 finding 1 (tests) is still fully open: zero test files,
npm run testis--passWithNoTests, andpr.ymldoesn't runapp2:dev— green CI proves typecheck and nothing more for ~10.7k new lines of money-path UI. - The PR description still describes the static single-file drop-in — worth rewriting so approvals are based on what the PR now is.
Code findings
Details are inline. The two critical ones:
- New users are bricked in all three trade modes: every gate that explains/unblocks (email verification, KYC/limits, min/max) renders only inside the PaymentSheet, which only opens on a valid quote — and a new user's every quote fails (
EmailRequired/RecommendationRequired). Disabled CTA, "quote error", no way forward. (home.tsx:312) - Persisted WalletConnect sessions survive logout and pre-empt new pairings: silent auto-login as the previous wallet on shared machines, and a self-bricking hang after a phone-side disconnect, with no reachable code path that clears the stale session. (providers.ts:127)
High-severity, inline: fiat buyable/sellable filters inverted against the API convention (home.tsx:615); card/instant offered although the API rejects card unconditionally (PaymentMethodPicker.tsx:45); chain reachability wrong in both directions — blockchains[0] hides valid EVM chains (session.tsx:190) while the asset-pool fallback re-offers Lightning to non-LN wallets (asset-pool.ts:66); SEPA details missing the beneficiary (PaymentSheet.tsx:413); sell quotes fired without the mandatory IBAN and the 400 latched into the payment snapshot (useTradeQuote.ts:60); the cancel fix only covers the QR phase (session.tsx:238); injected addresses sent to /auth lowercase (providers.ts:102); Sumsub ident sessions dead-end silently (kyc.tsx:58); KYC data-entry steps loop with no portal link (kyc.tsx:366); failed support messages silently lost (support.tsx:255); PDF attachments can't be opened (support.tsx:507).
Not inline but worth fixing in the same pass: no accountsChanged/chainChanged handling anywhere in src/app2 (switching MetaMask accounts leaves the session — and sell instructions — on the old address; production re-verifies, src/hooks/wallets/metamask.hook.ts:111); no error boundary above the router (App.tsx — a provider throw is an unbranded error page in a money app); the Sheet background is neither inert nor focus-trapped, which is also what lets the "frozen" snapshot re-latch under an open sheet (home.tsx:287); ticket/thread load failures render as empty states ("No tickets yet") with unhandled rejections (support.tsx:167); transactions fetch and render the full history unbounded (transactions.tsx:132); the referral row copies the bare code while toasting "Referral link copied" (account.tsx:263); Drawer's Sell/Swap items route to / without setting the mode (Drawer.tsx:53) — decorative today.
Verified as claimed — credit where due
- Zero hand-rolled fetches; zero new dependencies — both checked mechanically. All hook usage exists in the pinned lib version. (One version edge:
err.code === 'TFA_REQUIRED'in kyc.tsx:145 needs ≥1.4.1 — fine for the locked build, silently dead when developing against a stale local install; worth a note.) - i18n claim verified: 659 keys × 4 languages, zero missing/mismatched, placeholder tokens consistent, all dynamic enum keys (
kn_*,ks_*,is_*) match the lib exactly. - Round-1 finding 4 properly fixed: real
URLparsing, rejectsjavascript:/data:/protocol-relative, applied at every API-derived href/window.opensink I could find — stricter than production, which iframes the ident URL unchecked. (Two identical copies of the helper exist —format.ts:89andamount.ts:85— merge them before they drift.) - No HTML-injection surface: no
dangerouslySetInnerHTML/innerHTMLin src/app2; the API-supplied QR-bill SVG is deliberately rendered viaimgdata-URI; everytarget="_blank"carriesnoopener. - The quote engine's ordering discipline (monotonic seq + key-tied freshness) is sound; there is zero client-side money arithmetic on the payment path; copy buttons copy raw machine values; the magic-link flow returns to
/app2/(not the root app), scrubs credentials from the URL, andauthMsgOkrefusing to blind-sign an unrelated challenge is a protection most dapps skip.
What I'd gate merge on
- Resolve the two-apps state: wire
app2:devintopr.yml, deployapp2-distindev.yml, decouplebuild-app2.shfrompublic/app2, then delete the static tree. - The two criticals + the fiat-flag inversion.
- A CSP for the React artifact (meta tag in the template or headers at the edge).
- A committed smoke-level test suite — the money-path fixes in this PR's own history are exactly the regressions tests should be pinning.
- Updated PR description.
|
@Danswar I addressed the complete round-2 review in commit Highlights:
Local verification:
I left the review threads open for your verification. |
There was a problem hiding this comment.
Review round 3 (22dd0cc)
Round-2 verification first: all 20 threads check out against the code — resolved. The structural fix is genuinely complete (CI builds and deploys app2-dist in all three workflows, static tree gone, CSP injected with fail-closed assertions), and the regression suite is real: every test imports the production modules, and this PR's own CI log shows PASS src/app2/__tests__/review-regressions.test.ts. Credit also for: i18n mechanically clean (660 keys x 4 languages, zero drift, all literal t() usages resolve), the clean-room import boundary holds (nothing escapes src/app2/ except the SDK and existing lockfile deps), the CSP checks out against the app's actual runtime traffic (API, WC relay/verify, blob attachments, self-hosted fonts), and build-app2.sh's EXIT trap correctly restores the entry swap on failure so a broken app2 build can't poison the widget build that runs after it.
This round: one finding I'd gate merge on, a band of mediums inline, and grouped smalls below.
Gate: the WalletConnect storage cleanup is a no-op after any page reload (providers.ts:228, details inline) — the round-2 fix only holds within a single page lifetime, and any reload re-opens the shared-machine path round 2 called critical.
Inline (mediums): URL-credential bootstrap dead-ends silently on RecommendationRequired; prod-hardcoded KYC/setup handoffs break the dev artifact; 2FA setup failure is an unrecoverable spinner; account-merge/switch responses loop; the sell sheet can auto-open unprompted; the buy rate renders a dangling slash; TranslationKey collapses to string; Drawer claims aria-modal without modal behavior; the postprocess identity strips are unasserted; support retry/timeout/empty-thread delivery gaps.
Smaller, fine as a fast-follow:
- The swap rate is computed client-side (
estimatedAmount / quote.amount, fee-inclusive) although the Swap response carriesexchangeRate— the one breach of the zero-client-money-math claim (FeesPanel.tsx:86); thefees.bank ??component-sum fallback on :83 is dead per the SDK type. "1,000"parses as 1.0 (amount.ts:14-22) — the mismatch is visible in the receive field, but it yields a wrong-amount quote rather than a rejection.AMOUNT_TOO_LOW/HIGHopens a gate titled "Setup required" with an external app.dfx.swiss CTA when the fix is editing the amount locally (PaymentSheet.tsx:315-338); theisCardbranch at :364 is dead code now that Bank is the only method.- Transaction states render as raw enum strings in all four languages (transactions.tsx:273); the language switch never propagates to the API user (LanguageSheet.tsx:22-26), so server-driven mails/ident sessions stay in the account's old language.
- CSP:
https://pulse.walletconnect.orgis missing from connect-src (telemetry fetch blocked, console noise on every WC connect); the single static CSP shipslocalhost:3000/dev-API entries in the prod artifact; and a meta-delivered CSP cannot carryframe-ancestors, so /app2 clickjacking protection needs a response header at the edge (infra, not this repo). - app2's content-hashed bundles upload at
max-age=3600instead of immutable, and app2 PNGs upload twice (86400 then 3600) — matches the widget precedent, perf nit only. WC_OPTIONAL_CHAINSclaims to mirror wagmi.config.ts but omits Citrea — harmless while app2 only doespersonal_sign; fix the list or the comment before the first on-chain feature.- No global 401-to-logout: an idle tab whose JWT expires stays visually logged in until the next data call. Shared gap with the production app, noting for parity, not as a regression.
One deploy-scope flag: this commit wires app2 into prd.yml for the first time — merging now publishes to app.dfx.swiss/app2/, where the previous state was dev-only. That matches the updated PR description; just making sure it's a conscious call.
| * provider means the *next* connect attempt always starts from a fresh instance instead of | ||
| * reusing (and getting stuck behind) this one. */ | ||
| export async function disconnectWalletConnect(): Promise<void> { | ||
| if (!wcProviderPromise) return; |
There was a problem hiding this comment.
The WC cleanup is a no-op after any page reload — the shared-machine leak from round 2 survives via a different path. wcProviderPromise is module-level memory, so on a fresh page load this early-return fires while the previous session still sits in localStorage under wc@2:* — nothing in app2 ever clears those keys (clearStaleSessionOnCredentialedLoad removes only the DFX token and sessionStorage, and EthereumProvider.init runs with default persistent storage). Sequence: WC login -> reload -> logout (this function no-ops) -> next visitor clicks WalletConnect -> init() restores the persisted session and enable() resolves as the previous wallet, with no QR ever shown.
The round-2 fix holds within a single page lifetime only. Fix: when no live provider exists, clear the wc@2:*/@walletconnect storage keys directly (or unconditionally init-then-disconnect) — both here and on the logout path.
| showToast(`${t('connected')} · ${shortAddress(creds.address)}`); | ||
| } catch (error) { | ||
| if (!stillCurrent()) return; | ||
| if (needsRecommendation(error)) { |
There was a problem hiding this comment.
This branch dead-ends for the URL-credential bootstrap: the effect at :445 calls signInWith(...) with the connect sheet closed, and this catch sets the recommend view but never setSheetOpen(true) — and returns before any toast. An invite-gated user arriving via ?address=&signature= sees a silent no-op, and a later openConnect() resets the view to list, discarding the still-valid pending signature. setSheetOpen(true) alongside this setView fixes both.
| } | ||
|
|
||
| function portalKycUrl(code: string): string { | ||
| return `https://app.dfx.swiss/kyc?code=${encodeURIComponent(code)}`; |
There was a problem hiding this comment.
Hardcoded prod origin: grep process.env src/app2 comes back empty, so the dev artifact on dev.app.dfx.swiss/app2 hands users to https://app.dfx.swiss/kyc?code=<dev-code> — a code the prod API has never seen, so the handoff dead-ends. Same for the setup-gate link in PaymentSheet.tsx:317. build-app2.sh sets a per-env PUBLIC_URL for exactly this purpose; nothing reads it. (The prod referral link in account.tsx:179 is arguably intended — referrals live on prod — but the KYC/setup handoffs need to be env-aware.)
| setPhase({ kind: 'tfa', info, alreadyEnrolled: false }); | ||
| setBusy(false); | ||
| kyc | ||
| .setup2fa(code) |
There was a problem hiding this comment.
A non-"already" setup2fa failure (network blip, 5xx, non-English server message) leaves setup: undefined, alreadyEnrolled: false — the render at :250 is then a permanent LoadingRow and the verify form stays gated behind (alreadyEnrolled || setup): no retry, no escape from the tfa phase. Separately, /already/i on a human-readable message is the same message-sniffing errors.ts just moved off of — branch on the structured code/status instead, and give the failure state a retry + back-to-overview affordance.
| </div> | ||
| <div className="sectionlabel tight">{stepNameLabel(t, step.name)}</div> | ||
| <div className="paybox-note warn" style={{ margin: '10px 0' }}> | ||
| {t('kycFailed')} |
There was a problem hiding this comment.
Production's callKyc handles 401 with switchToCode, 409 exists/merge, and CONTACT_DATA FAILED with ACCOUNT_MERGE_REQUESTED (kyc.screen.tsx:256-270). App2 maps all of these to the generic genErr, and this FAILED branch offers only a Continue that re-runs continueKyc onto the same step — for a merge-requested account that's a closed loop with no path forward. At minimum, surface the merge/switch cases distinctly and link the portal here like the data-entry fallback does.
| const rateStr = isSwap | ||
| ? `${formatAmount(quote.amount ? quote.estimatedAmount / quote.amount : 0, 6, language)} ${receiveAssetCode} / ${payAssetCode}` | ||
| : mode === 'buy' | ||
| ? `${formatFiat(quote.exchangeRate, currencyCode, language)} / ${payAssetCode}` |
There was a problem hiding this comment.
Buy mode passes payAssetCode='' (home.tsx:626), so this renders € 95'000.00 / — dangling slash, no unit. The buy rate is currency-per-asset, so the unit here should be receiveAssetCode (production renders exchangeRate as currency/asset). The sell branch below it only works because payAssetCode happens to be the crypto code there.
|
|
||
| type Dict = Record<string, string>; | ||
|
|
||
| export const en: Dict = { |
There was a problem hiding this comment.
The explicit : Dict annotation widens en to Record<string, string>, so TranslationKey = keyof typeof en (:2736) collapses to string — the canonical key set the comment there describes doesn't actually exist, t('anyTypo') compiles, and a renamed key ships silently rendering the raw key literal. export const en = {...} as const satisfies Dict (or dropping the annotation) restores compile-time key checking. Today's key set is mechanically clean — 660 keys x 4 languages, every literal usage resolves — this is the guard that keeps it that way.
| ref={ref} | ||
| className={`drawer${open ? ' on' : ''}`} | ||
| role="dialog" | ||
| aria-modal="true" |
There was a problem hiding this comment.
aria-modal="true" with none of the modal behavior: unlike Sheet (ui.tsx: background inert walk, focus trap, Escape, focus restore), the open drawer doesn't inert the page behind it, trap focus, or close on Escape — useInertWhenClosed only inerts the drawer itself when closed. As-is the attribute misinforms screen readers (announces the background as inert when Tab happily traverses it behind the scrim). Either wire the Sheet's focus/inert machinery or drop the attribute.
| html = html | ||
| .replace(/<link rel="icon" href="[^"]+"\s*\/?>(?:<link rel="icon"[^>]+>)?/, '') | ||
| .replace(/<link rel="apple-touch-icon" href="[^"]+"\s*\/?>/, '') | ||
| .replace(/<link rel="manifest" href="[^"]+"\s*\/?>/, '') |
There was a problem hiding this comment.
These identity strips are attribute-order-sensitive and, unlike the loader/CSP strips at :93-96, unasserted — a template reformat or attribute reorder makes them silently no-op, and the built HTML then keeps the main app's <link rel="manifest" href=".../manifest.json"> before the injected ./manifest.webmanifest; first-manifest-wins means /app2 installs with the main app's PWA identity again — the exact class this PR just fixed. Cheap insurance: assert !html.includes('manifest.json'), !html.includes('fonts.googleapis.com'), and no absolute-origin apple-touch-icon, alongside the existing throws.
|
|
||
| const send = () => startSend(composer.trim() || undefined, pendingFile, true); | ||
|
|
||
| const retryMessage = (failedMessage: SupportMessage) => { |
There was a problem hiding this comment.
Three related delivery-tracking gaps: (a) a successful retry creates a new optimistic message but nothing settles or removes the original FAILED bubble — the thread permanently shows a failed copy whose still-live Retry delivers a duplicate to support; (b) on the 30s settle-timeout (:231-240) the optimistic bubble keeps rendering the "Sending…" suffix (:678) forever with no failure conversion or retry affordance; (c) setSync(!!activeUid) (:203) enables the SDK's 5s sync even for zero-message threads, and syncSupportIssue reads messages[messages.length - 1].id unguarded (support.context.js:111) — a TypeError per tick on exactly the empty threads the render at :533 acknowledges exist.
DFX App 2.0 — React preview at
/app2/This PR ships the standalone App2 React target alongside the existing production app for stakeholder review. App2 is built from
src/app2/, uses the shared@dfx.swiss/reactAPI layer, and is deployed as its own isolated artifact under/app2/.What ships
scripts/build-app2.shproducesapp2-dist/;scripts/postprocess-app2.jsremoves shared main-app public files, stages only App2 PWA assets, and injects App2's CSP.app2-dist/, stage it atbuild/app2/, upload it to/app2/*, and re-uploadapp2/index.htmlwithno-cache, must-revalidate.public/app2/static app, vendored browser bundles, and vendored WebAssembly are deleted.Review hardening
Security and artifact isolation
app2/, main-app manifest/logo/version files, legacy vendor bundles, or vendored WASM.Verification
npm run lintnpm test -- --runInBand— 29 suites, 318 tests, including 8 App2 review regressionsnpm run build:devnpm run widget:devnpm run app2:devnpm run app2(production)Deploy behavior
On merge to
develop, DEV publishesdev.app.dfx.swiss/app2/index.html. The production workflow publishes the same isolated React target toapp.dfx.swiss/app2/index.html. The existing root app remains in place.