import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { expect, test } from 'vitest' import { Either, Schema as S } from 'effect' import { Ui, ui } from '@playfast/reform' import type { WireNode } from '@playfast/reform/internal' import { remoteViews, renderWireTree, type RemoteViews } from './client' /** * Round-4 adversarial audit — the transport-decode fix, sibling half. * * `reform-remote-web/src/transport.ts` moved `decodeServerMessage` from * `Schema.decodeUnknownSync` to `Schema.decodeUnknownOption` so a frame this * client cannot parse costs that frame and nothing else. That fix is sound for * the *envelope*. * * It does not reach the second `decodeUnknownSync` on the same path. The frame * schema types a data prop as `value: Schema.Unknown` (transport.ts, WirePropSchema), * so ANY JSON value passes the envelope decode and is handed to the tree. The * per-view props schema is applied later, in `client.ts`: * * view: (encoded, slots, events) => * exact(Schema.decodeUnknownSync(propsSchema)(encoded), ...) * * and `registered.view(...)` is the return statement of `WireNodeView`, a React * component. A prop the client's schema rejects — version skew against a newer * server, a hostile endpoint, a hand-rolled server — therefore throws out of * React's render phase, where the blast radius is not one node but every sibling * in the tree: with no error boundary React unmounts the whole root. * * The two halves of the same frame path now answer the same untrusted input in * two different ways. */ class AuditItemUi extends ui('audit4.Item', { props: S.Struct({ label: S.String }) }) {} const AuditItemView = Ui.make(AuditItemUi, ({ label }) => createElement('span', { 'data-item': label }, label), ) const node = (over: Partial & Pick): WireNode => ({ parentId: null, childIndex: 0, slot: null, key: null, props: [], ...over, }) type AuditContract = { Item: typeof AuditItemUi } const views: RemoteViews = { Item: AuditItemView } const render = (tree: ReadonlyArray): string => renderToStaticMarkup( renderWireTree(tree, { views: remoteViews(views), invoke: () => undefined, }), ) // The frame the server actually put on the wire. `label` is a string on the // server's schema and a number here — the plainest form of version skew, and // exactly what `WirePropSchema`'s `value: Schema.Unknown` lets through. const wellFormed = node({ id: 'ok', name: 'audit4.Item', childIndex: 0, props: [{ _tag: 'Data', name: 'label', value: 'still-here' }], }) const skewed = node({ id: 'skewed', name: 'audit4.Item', childIndex: 1, props: [{ _tag: 'Data', name: 'label', value: 7 }], }) // The render is what is under test, so its outcome is data, not an exception. const attempt = (tree: ReadonlyArray): Either.Either => Either.try(() => render(tree)) test('a wire prop this client cannot decode costs that node, not the whole render', () => { // CONTROL — the harness renders, the view is registered, and a well-formed // node produces its markup. expect(Either.getOrElse(attempt([wellFormed]), () => '')).toContain('still-here') // CONTROL — a node whose NAME this client does not know is already handled the // way the fix asks for: `WireNodeView` returns null for it and its siblings // render. Rejecting a prop is the same class of skew, one field deeper. const unknownView = node({ id: 'unknown', name: 'audit4.NotRegistered', childIndex: 1 }) expect(Either.getOrElse(attempt([wellFormed, unknownView]), () => '')).toContain('still-here') // THE DEFECT — one node the client's props schema rejects takes the render // down with it, so the sibling that decoded fine is never painted either. const skewedRender = attempt([wellFormed, skewed]) expect(Either.isRight(skewedRender)).toBe(true) expect(Either.getOrElse(skewedRender, () => '')).toContain('still-here') })