open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.js
Transaction Science 760e5c343d wai: register wai.live.ad_presentation; a reference producer in the browser client; CI
The registry lists the record and the WPC1 magic (the export regenerated), SPEC
§5 describes the claim and the billing rule, and the CHANGELOG records them.
joule-moq-web gains web/ads/presentation.js, a span recorder over a video
element and the claim's writer and signer on WebCrypto's Ed25519, whose node
tests turn observation sequences into the corpus's claims byte for byte; no page
plays ads yet. wai-conformance runs the module's tests, the corpus test,
ad_presentation_verify.py and the producer's tests; joule-moq-web's e2e script
runs the producer's tests with the page's other unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:23:52 -04:00

242 lines
9.8 KiB
JavaScript

// The reference producer of WAI ad-presentation claims (wai/extensions/ad-presentation.md):
// what a player measures while it presents one ad of a live-ads break, turned into spans by
// the rules of §10, and the WPC1 claim (§5) written and signed with the session key (§3).
//
// SpanRecorder observations (media position, playing, visible fraction, audible) to spans
// attach a video element's observations, every 200 ms and on its events
// claimBody the claim's bytes before its signature (§5.1, §5.2)
// signClaim the claim, signed with the platform's Ed25519 (§5.3)
// sessionOf, creativeDigest, touched, redemptionContext, tokenChallenge (§3, §4, §7.2)
//
// Everything here is the platform's: WebCrypto's SHA-256 and Ed25519, IntersectionObserver,
// the media element. No dependency. An IntersectionObserver measures the element's box against
// the viewport, not what other elements cover; a page that can cover its player states
// geometry 3 (the player's area) or measures what covers it itself (§10).
const enc = new TextEncoder();
const subtle = globalThis.crypto.subtle;
export const CLAIM_MAGIC = enc.encode("WPC1");
export const CLAIM_DOMAIN = enc.encode("wai:ad-presentation-claim\x01");
export const SESSION_DOMAIN = enc.encode("wai:ad-presentation-session\x01");
export const ATTEST_DOMAIN = enc.encode("wai:ad-presentation-attest\x01");
export const CREATIVE_DOMAIN = enc.encode("wai:ad-creative\x01");
export const NOT_EXPOSED = 0xffffffff;
export const GEOMETRY = { AD: 1, SCREEN: 2, PLAYER: 3 };
export const AUDIBLE = 1;
export const RESUMED = 2;
const concat = (...parts) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) {
out.set(p, at);
at += p.length;
}
return out;
};
const u16le = (v) => Uint8Array.of(v & 0xff, (v >>> 8) & 0xff);
const u32le = (v) => {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, v >>> 0, true);
return b;
};
export async function sha256(...parts) {
return new Uint8Array(await subtle.digest("SHA-256", concat(...parts)));
}
/** The session a session key names in a room (§3). */
export async function sessionOf(room, key) {
return (await sha256(SESSION_DOMAIN, room, key)).subarray(0, 16);
}
/** The creative digest of objects `[{ contentHash, durationMs }]` (§4). */
export async function creativeDigest(objects) {
return sha256(CREATIVE_DOMAIN, u32le(objects.length), ...objects.flatMap((o) => [o.contentHash, u32le(o.durationMs)]));
}
/** The content hashes of the objects the spans touch, in order (§9.2 step 6). */
export function touched(objects, spans) {
const out = [];
let s = 0;
for (const o of objects) {
const e = s + o.durationMs;
if (spans.some((x) => x.startMs < e && x.startMs + x.lengthMs > s)) out.push(o.contentHash);
s = e;
}
return out;
}
/** The redemption context binding an attestation to one impression and session key (§7.2). */
export async function redemptionContext(c) {
return sha256(ATTEST_DOMAIN, c.room, c.cue, c.session, Uint8Array.of(c.slot), c.creativeHash, c.player);
}
/** RFC 9577 §2.1.1's TokenChallenge with this redemption context and no origin_info (§7.2). */
export function tokenChallenge(tokenType, issuer, context) {
const name = typeof issuer === "string" ? enc.encode(issuer) : issuer;
return concat(Uint8Array.of(tokenType >>> 8, tokenType & 0xff, name.length >>> 8, name.length & 0xff), name, Uint8Array.of(32), context, Uint8Array.of(0, 0));
}
/**
* Spans by the rules of §10: a span is media time presented at normal speed; a new one begins
* whenever the quantised visible fraction or the audible state changes, or playback stops,
* skips or is seeked (then the next one is a resumption); its visible fraction is the least
* observed over it; and media time already presented is never stated again.
*/
export class SpanRecorder {
/** `quantumBp`: the visible fraction's step, in basis points (100 is OMID's whole percent). */
constructor({ quantumBp = 100 } = {}) {
this.quantum = quantumBp;
this.done = [];
this.cur = null; // { startMs, endMs, visibleBp, cls, audible }
this.maxEnd = 0; // the end of the media time stated so far
this.resumeNext = false;
}
close() {
if (this.cur && this.cur.endMs > this.cur.startMs) {
const flags = (this.cur.audible ? AUDIBLE : 0) | (this.cur.resumed ? RESUMED : 0);
this.done.push({ startMs: this.cur.startMs, lengthMs: this.cur.endMs - this.cur.startMs, visibleBp: this.cur.visibleBp, flags });
this.maxEnd = Math.max(this.maxEnd, this.cur.endMs);
}
this.cur = null;
}
/** One observation: `{ positionMs, playing, visibleBp, audible, seeked }`. */
observe({ positionMs, playing, visibleBp, audible, seeked = false }) {
const pos = Math.max(0, Math.floor(positionMs));
if (!playing || seeked) {
this.close();
if (this.done.length > 0) this.resumeNext = true;
if (!playing) return;
}
const vis = Math.max(0, Math.min(10_000, Math.round(visibleBp)));
const cls = Math.floor(vis / this.quantum);
if (this.cur) {
if (pos < this.cur.endMs) return; // no media time stated twice
if (cls !== this.cur.cls || !!audible !== this.cur.audible) {
this.cur.endMs = pos;
this.close();
} else {
this.cur.endMs = pos;
this.cur.visibleBp = Math.min(this.cur.visibleBp, vis);
return;
}
}
if (pos < this.maxEnd) return; // still within media time already presented
const continues = !this.resumeNext && this.done.length > 0 && pos === this.maxEnd;
const resumed = this.done.length > 0 && !continues;
this.cur = { startMs: pos, endMs: pos, visibleBp: vis, cls, audible: !!audible, resumed };
this.resumeNext = false;
}
/** The spans, the open one closed at its last observation. */
spans() {
this.close();
return this.done.slice();
}
}
/**
* Observe a video element: every `intervalMs` (at most the 200 ms of §10) and on its playback,
* volume and seek events, and on the page's visibility and focus. A fraction of 0 while the
* page is hidden or unfocused; audible while unmuted at a volume above 0; normal speed only.
*/
export function attach(video, recorder, { intervalMs = 200 } = {}) {
let ratio = 0;
let seeked = false;
const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);
const io = new IntersectionObserver((entries) => {
for (const e of entries) ratio = e.intersectionRatio;
}, { threshold: thresholds });
io.observe(video);
const tick = () => {
const shown = document.visibilityState === "visible" && document.hasFocus();
recorder.observe({
positionMs: video.currentTime * 1000,
playing: !video.paused && !video.ended && video.readyState >= 3 && video.playbackRate === 1,
visibleBp: shown ? ratio * 10_000 : 0,
audible: !video.muted && video.volume > 0,
seeked,
});
seeked = false;
};
const onSeek = () => {
seeked = true;
tick();
};
const events = ["play", "pause", "ended", "waiting", "playing", "volumechange", "ratechange"];
for (const e of events) video.addEventListener(e, tick);
video.addEventListener("seeking", onSeek);
document.addEventListener("visibilitychange", tick);
window.addEventListener("blur", tick);
window.addEventListener("focus", tick);
const timer = setInterval(tick, Math.min(intervalMs, 200));
return {
/** Frames presented and not dropped, where the element exposes them (§5.1 `frames`). */
frames() {
const q = video.getVideoPlaybackQuality?.();
return q ? Math.max(0, q.totalVideoFrames - q.droppedVideoFrames) : NOT_EXPOSED;
},
stop() {
tick();
clearInterval(timer);
io.disconnect();
for (const e of events) video.removeEventListener(e, tick);
video.removeEventListener("seeking", onSeek);
document.removeEventListener("visibilitychange", tick);
window.removeEventListener("blur", tick);
window.removeEventListener("focus", tick);
return recorder.spans();
},
};
}
/** The claim's bytes before `sig` (§5.1, §5.2). */
export function claimBody(c) {
const spans = c.spans.flatMap((s) => [u32le(s.startMs), u32le(s.lengthMs), u16le(s.visibleBp), Uint8Array.of(s.flags)]);
const att = c.attestation ?? new Uint8Array(0);
return concat(
CLAIM_MAGIC,
Uint8Array.of(1),
c.room,
c.cue,
c.session,
Uint8Array.of(c.slot),
c.creativeHash,
c.player,
Uint8Array.of(c.geometry),
u32le(c.frames ?? NOT_EXPOSED),
u16le(c.objects.length),
...c.objects,
u16le(c.spans.length),
...spans,
u16le(att.length),
att,
);
}
/** A session key from 32 bytes of seed (the corpus's keys; a player makes a fresh one). */
export async function sessionKeyFromSeed(seed) {
const pkcs8 = concat(Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20), seed);
const privateKey = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
const jwk = await subtle.exportKey("jwk", privateKey);
const b64 = jwk.x.replace(/-/g, "+").replace(/_/g, "/");
const pub = Uint8Array.from(atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)), (ch) => ch.charCodeAt(0));
return { privateKey, publicKey: pub };
}
/** A fresh session key for one playback session of one room (§3). */
export async function newSessionKey() {
const k = await subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
return { privateKey: k.privateKey, publicKey: new Uint8Array(await subtle.exportKey("raw", k.publicKey)) };
}
/** The claim, signed (§5.3): its body and Ed25519 over the domain and the body. */
export async function signClaim(c, privateKey) {
const body = claimBody(c);
const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, concat(CLAIM_DOMAIN, body)));
return concat(body, sig);
}