caputchin
All docs
View raw .md

Widget

The customer-facing browser surface — a single web component plus two events. Distributed two ways, identical behavior:

  • CDN: https://cdn.jsdelivr.net/npm/@caputchin/widget@1/dist/widget.js (script tag, auto-registers). See ADR-0009 for why we use jsDelivr instead of a custom CDN.
  • npm: @caputchin/widget (browser-runtime only). The game-author register() helper is a separate package, @caputchin/game-sdk — see ADR-0008.

Element shape

<caputchin-widget sitekey="cpt_pub_..." game="@cooperative-games/bubble-pop"></caputchin-widget>
Attribute Required Behavior
sitekey yes Public site key from the dashboard
game optional Marketplace game ID, or a customer-chosen ID used together with game-src
games optional Comma-separated list of marketplace IDs; widget picks one at random client-side
game-src optional Absolute or page-relative URL to a customer-hosted game script. Required when the game ID is not a marketplace ID. See game-distribution.
mode optional Trigger and control mode. See Modes.

If neither game / games nor game-src is set, the widget runs the invisible default — Cap-only verification, no game UI, score: null in the wrapped token.

On successful completion the widget injects a hidden <input name="caputchin-token"> into the enclosing form. Customer's backend posts that field to /siteverify with the secret.

Events

const widget = document.querySelector('caputchin-widget');
widget.addEventListener('start',    (e) => { /* e.detail.gameId */ });
widget.addEventListener('complete', (e) => { /* e.detail.token, score, durationMs */ });
widget.addEventListener('nickname', (e) => { /* e.detail.nickname */ });
widget.addEventListener('error',    (e) => { /* e.detail.code, message, originalCode */ });
Event Fires when event.detail
start Game has begun. In iframe modes (auto+game, form-submit+game), fires when the iframe sends game-started. In the invisible default (no game) and manual mode, fires when Cap solve() begins — no iframe is involved so there is no game-started to wait for. { gameId } — the resolved game ID, or null in the invisible default
complete Wrapped token is built (first completion only) { token, score, durationMs }token is also injected as caputchin-token into the enclosing form. score / durationMs are nullable.
nickname Scoreboard handle was set, either by the widget's own prompt or by widget.setNickname() (Post-MVP) { nickname } — the 3-letter string
error Anything blocks the flow { code, message, originalCode? }code is a value from the widget's documented ErrorCode set. originalCode is set only when a game calls bridge.error({ code }) with a code outside that set; code is then game-error-relayed and originalCode carries the author-supplied value verbatim.

The start event ordering depends on whether a game is configured. In iframe modes (auto+game, form-submit+game), Cap's solve() begins in parallel with the game loading inside the sandboxed iframe. The start event fires only after the iframe sends game-started — confirming the game actually loaded and ran — so customers can rely on start as a signal that gameplay has begun, not merely that Cap has started churning. In the invisible default (no game) and in manual mode there is no iframe, so start fires immediately when Cap begins.

Every programmatic method fires the corresponding event so customers can observe progress in any mode without owning the call site. By design — see principles. Anything fancier (per-round score deltas, stats, replay timelines) belongs in the dashboard, not in widget callbacks.

Modes

The mode attribute controls when Cap starts and who drives the lifecycle. Game presence is orthogonal to the trigger: any non-manual mode supports either an iframe-hosted game (when game / game-src is set) or the invisible default (when neither is set).

mode value When Cap fires Driver Compatible with game / game-src
(unset) / auto On widget mount Widget Yes — game loads at the same moment
form-submit When the enclosing <form> is submitted; submission is held until the wrapped token is ready Widget intercepts form submit Yes
manual When the customer calls widget.start() Customer fully drives lifecycle from the host page realm; widget loads no game No — setting game or game-src together with mode="manual" emits error with code invalid-config

manual is the deliberate opt-out from sandbox isolation. The customer's own host-realm code runs whatever game / UX they like and calls the widget back to drive Cap. There is no third-party code involved by definition, so the iframe contributes nothing — the customer trusts their own bundle the same way they trust the rest of their app. Marketplace and customer-hosted modes remain sandboxed; manual is explicit and verbose in markup so the trust trade is visible at the integration point. See principles — untrusted by default and ADR-0015.

Programmatic API (manual mode)

const widget = document.querySelector('caputchin-widget');

widget.start();                          // begin Cap solve; held until complete()
widget.complete({ score, durationMs });  // release redeem; emit complete event
widget.setNickname('AAA');               // Post-MVP scoreboards only; optional
Method Effect
widget.start() Calls Cap solve() in the widget realm, fires /game/start. Idempotent — repeat calls are no-ops.
widget.complete({ score, durationMs }) Releases the gated Cap redeem via CAP_CUSTOM_FETCH, fires /game/complete, builds the wrapped token, emits the complete event. Repeat calls record additional rounds; the wrapped token is locked at the first call. Either field is optional and metadata-only — see game data is metadata.
widget.setNickname(letters) (Post-MVP, scoreboards) Not yet implemented — throws Error('not implemented') in the current build. Will call /game/nickname when the platform endpoint ships.

Only available in manual mode. In auto and form-submit modes the widget owns the lifecycle and these methods are not exposed.

Lifecycle

%%{init: {'themeVariables': {'actorBkg':'#6a4c93','actorBorder':'#3d2a5e','actorTextColor':'#fff'}}}%%
sequenceDiagram
    participant W as Widget
    participant C as Cap
    participant I as Iframe
    participant P as Platform API

    alt auto / form-submit mode (with game)
        W->>W: connectedCallback / form submit
        W->>P: GET /games/:id/resolve
        P-->>W: { url, integrity }
        W->>I: mount srcdoc iframe
        W->>C: cap.solve()
        W->>P: POST /game/start
        I->>I: load & run game script
        I-->>W: postMessage game-started
        W-->>W: emit start event { gameId }
        I-->>W: postMessage game-complete { score, durationMs }
        W->>W: update platform { score, durationMs }
        W->>C: releaseRedeemGate()
        C->>P: POST /game/complete (redeem)
        P-->>C: { powToken, wrappedToken }
        C-->>W: solve() resolves
        W->>W: assemble wrappedToken
        W->>W: inject caputchin-token input
        W-->>W: emit complete event
    else invisible default (no game configured)
        W->>W: connectedCallback
        W->>C: cap.solve()
        W->>P: POST /game/start
        W->>W: releaseRedeemGate() immediately
        W-->>W: emit start event { gameId: null }
        C->>P: POST /game/complete (redeem)
        P-->>C: { powToken, wrappedToken }
        C-->>W: solve() resolves
        W->>W: assemble wrappedToken (score: null)
        W->>W: inject caputchin-token input
        W-->>W: emit complete event
    else manual mode
        W->>W: widget.start() called by customer
        W->>C: cap.solve()
        W->>P: POST /game/start
        W-->>W: emit start event { gameId: null }
        Note over W,I: No iframe. Customer drives game UX in host page.
        W->>W: widget.complete({ score, durationMs }) called by customer
        W->>W: update platform { score, durationMs }
        W->>C: releaseRedeemGate()
        C->>P: POST /game/complete (redeem)
        P-->>C: { powToken, wrappedToken }
        C-->>W: solve() resolves
        W->>W: assemble wrappedToken
        W->>W: inject caputchin-token input
        W-->>W: emit complete event
    end
    Note over W,P: Token locked at first completion. Subsequent rounds record score only.

The widget defers expensive work until the user actually engages. Cap's solve() is not called on widget mount — it fires when the game starts, so users who never play don't generate PoW challenges. Game code runs inside a sandboxed cross-origin iframe mounted by the widget; the widget and the iframe communicate via postMessage. See ADR-0015 for the isolation model.

  1. Mount — render container in the host page, no network until the trigger fires. The iframe is not yet built.
  2. Trigger — depends on mode. In auto (default), the trigger is mount itself. In form-submit, it is the enclosing <form> submit event (which is gated until completion). In manual, it is the customer's widget.start() call. On trigger the widget resolves the game ID via /games/:id/resolve (for marketplace IDs) or uses the customer-supplied game-src (for customer-private games), builds the iframe srcdoc with a path-pinned script-src CSP, mounts the iframe, and calls Cap solve() plus /game/start in the host realm. In manual mode, no iframe and no game are loaded; only Cap runs.
  3. Game runs inside the sandboxed iframe (non-manual modes with game / game-src set) — opaque origin, no access to the customer's cookies, localStorage, DOM, or same-origin fetch. The iframe loads the game script via <script src> (with SRI for marketplace games); the game's top-level register() call writes into the iframe's window.Caputchin.games. The iframe runtime invokes the registered factory with a container element and a push-only bridge — see game-sdk. In the invisible default (no game configured), no iframe is mounted at all — Cap runs alone.
  4. Completion — for iframe-hosted games, the iframe relays bridge.complete({ score, durationMs }) to the widget via postMessage. For manual mode, the customer calls widget.complete({ score, durationMs }) directly. For the invisible default, Cap completing is itself the completion; the widget builds the wrapped token with score: null. In all cases the widget releases the held Cap redeem via CAP_CUSTOM_FETCH, which fires /game/complete.
  5. Wrapped token returned — widget drops caputchin-token into the form and emits the complete event.
  6. Subsequent completion calls (replay, multi-round) — widget calls /game/complete again for scoreboard recording, but the wrapped token is already locked.
  7. Optional 3-letter nickname prompt (scoreboard-enabled site keys only, Post-MVP) — surfaced after the first completion as a retro-style 3-character entry rendered by the widget in the host realm, not by the game inside the iframe. Non-blocking: the wrapped token and the complete event have already fired by this point, so the customer's form submission is unaffected. Widget posts the entry to /game/nickname; the user may close the tab and the score persists anonymously. Game authors are not involved — the widget owns this surface end-to-end. See ADR-0014.

Game loading

The widget resolves the game ID to a script URL and loads it inside the iframe — never in the host page realm. Two paths:

  • Marketplace ID (e.g., @cooperative-games/bubble-pop) — widget calls /games/:id/resolve and receives { url, integrity }. The iframe loads the script via <script src integrity>.
  • Customer-hosted game — customer passes a game-src attribute pointing at a script they host (their static-asset directory, any URL). The iframe loads that URL. No integrity hash because no canonical hash exists for customer-private code; the customer's own deploy pipeline is the trust root.

In both cases the game's top-level register() call writes into the iframe's own window.Caputchin.games — there is no host-page registry to write to. See game-distribution and ADR-0015.

Pool selection (games=)

When games="a,b,c" is set, the widget picks one ID at random per session. There is no server-side pool config, no allowlist, no game-pool lock — by design. See principles and ADR-0006 for the related "no fake config knobs" stance.

Isolation

The widget mounts every game inside an <iframe sandbox="allow-scripts" srcdoc="...">. Sandbox without allow-same-origin gives the iframe an opaque origin distinct from the customer's page. The iframe's inline CSP path-pins script-src to exactly the runtime URL plus the resolved game URL — no wildcards. Customer-side CSP is not affected by what runs inside the sandbox; customers do not need to allowlist jsDelivr or the game origin in their own policy. See ADR-0015 for the full threat model and the rationale for srcdoc over a dedicated subdomain.

Mobile

The widget works inside a WebView with no special configuration. The sandboxed iframe is supported across iOS and Android WebView builds in the project's support matrix. See mobile for the embed pattern.

Cap masking inside the widget

Runtime traffic (PoW challenge / redeem, /siteverify) originates from caputchin.com domains. The widget script itself loads from jsDelivr under the @caputchin/widget package name; Cap's WASM is inlined as base64 inside cap.min.js (pre-1.0), so widget.js is a single self-contained file — see ADR-0016 and cap-integration for the full masking posture.