diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index 55e627bff7..a085c98214 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -127,6 +127,12 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>( ConstructorKind::ToolCancelEvent => { build_value_event_template(scope, ValueEventKind::ToolCancel) } + ConstructorKind::PaymentRequestUpdateEvent => { + build_value_event_template(scope, ValueEventKind::PaymentRequestUpdate) + } + ConstructorKind::PaymentMethodChangeEvent => { + build_value_event_template(scope, ValueEventKind::PaymentMethodChange) + } ConstructorKind::UiEvent => { build_event_subclass_template(scope, EventSubclassKind::UiEvent) } diff --git a/moli-renderer-v8/src/context_bootstrap/events/value.rs b/moli-renderer-v8/src/context_bootstrap/events/value.rs index 9f2edbc972..cc768dfa65 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/value.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/value.rs @@ -21,6 +21,9 @@ const TOOL_NAME_SLOT: &str = "__moliToolEventName"; const GAMEPAD_SLOT: &str = "__moliValueEventGamepad"; const PICTURE_IN_PICTURE_WINDOW_SLOT: &str = "__moliPictureInPictureEventWindow"; +const PAYMENT_METHOD_NAME_SLOT: &str = "__moliPaymentMethodChangeEventMethodName"; +const PAYMENT_METHOD_DETAILS_SLOT: &str = "__moliPaymentMethodChangeEventMethodDetails"; + #[derive(Clone, Copy)] pub(in crate::context_bootstrap) enum ValueEventKind { Animation, @@ -31,6 +34,8 @@ pub(in crate::context_bootstrap) enum ValueEventKind { ToolCancel, Gamepad, PictureInPicture, + PaymentRequestUpdate, + PaymentMethodChange, } impl ValueEventKind { @@ -44,6 +49,8 @@ impl ValueEventKind { Self::ToolCancel => "ToolCancelEvent", Self::Gamepad => "GamepadEvent", Self::PictureInPicture => "PictureInPictureEvent", + Self::PaymentRequestUpdate => "PaymentRequestUpdateEvent", + Self::PaymentMethodChange => "PaymentMethodChangeEvent", } } @@ -219,6 +226,59 @@ struct PictureInPictureEventInit<'s> { picture_in_picture_window: v8::Local<'s, v8::Object>, } +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::PaymentMethodChangeEvent, enumerable, receiver)] +struct PaymentMethodChangeEventPrototypeDeclaration { + #[webapi(accessor_property = "methodName", getter = payload_getter, data = v8str(scope, PAYMENT_METHOD_NAME_SLOT))] + method_name: (), + #[webapi(accessor_property = "methodDetails", getter = payload_getter, data = v8str(scope, PAYMENT_METHOD_DETAILS_SLOT))] + method_details: (), +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "PaymentMethodChangeEventInit")] +struct PaymentMethodChangeEventInit<'s> { + #[webidl(inherit)] + base: EventInit, + #[webidl(nullable)] + method_details: Option>, + #[webidl(with = string_member)] + method_name: v8::Local<'s, v8::String>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::PaymentRequestUpdateEvent, enumerable, receiver)] +struct PaymentRequestUpdateEventPrototypeDeclaration { + #[webapi(method, length = 1, callback = payment_update_with_callback)] + update_with: (), +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "PaymentRequestUpdateEvent.updateWith")] +struct PaymentUpdateWithArgs<'s> { + #[webidl(required)] + _details_promise: v8::Local<'s, v8::Promise>, +} + +fn payment_update_with_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + if webidl::parse_args::(scope, &args).is_none() { + return; + } + // Only author-created payment events are currently produced. They must + // reject updates even during dispatch, after Web IDL Promise conversion. + // Trusted payment events require an interactive PaymentRequest backend. + crate::native_bridge::throw_dom_exception( + scope, + "InvalidStateError", + 11, + "updateWith requires a trusted event associated with an interactive payment request", + ); +} + fn string_member<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, @@ -265,6 +325,16 @@ pub(in crate::context_bootstrap) fn install_value_event_template_bindings<'s>( "GamepadEvent" => { GamepadEventPrototypeDeclaration::initialize_prototype_template(scope, prototype) } + "PaymentRequestUpdateEvent" => { + PaymentRequestUpdateEventPrototypeDeclaration::initialize_prototype_template( + scope, prototype, + ) + } + "PaymentMethodChangeEvent" => { + PaymentMethodChangeEventPrototypeDeclaration::initialize_prototype_template( + scope, prototype, + ) + } _ => {} } } @@ -306,6 +376,8 @@ fn value_event_constructor<'s>( Some(5) => ValueEventKind::ToolCancel, Some(6) => ValueEventKind::Gamepad, Some(7) => ValueEventKind::PictureInPicture, + Some(8) => ValueEventKind::PaymentRequestUpdate, + Some(9) => ValueEventKind::PaymentMethodChange, _ => return, }; if !args.is_construct_call() { @@ -427,6 +499,32 @@ fn value_event_constructor<'s>( parsed.base.composed, ) } + ValueEventKind::PaymentRequestUpdate => { + // PaymentRequestUpdateEventInit adds no members to EventInit. + let parsed = webidl::parse_dictionary_object::(scope, dictionary)?; + (parsed.bubbles, parsed.cancelable, parsed.composed) + } + ValueEventKind::PaymentMethodChange => { + let parsed = webidl::parse_dictionary_object::( + scope, dictionary, + )?; + let details = parsed + .method_details + .map(Into::into) + .unwrap_or_else(|| v8::null(scope).into()); + set_event_private_value(scope, state, PAYMENT_METHOD_DETAILS_SLOT, details); + set_event_private_value( + scope, + state, + PAYMENT_METHOD_NAME_SLOT, + parsed.method_name.into(), + ); + ( + parsed.base.bubbles, + parsed.base.cancelable, + parsed.base.composed, + ) + } ValueEventKind::Gamepad => { let parsed = webidl::parse_dictionary_object::(scope, dictionary)?; diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs index d4c8ab6bb4..61b870a9aa 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -107,6 +107,8 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = ]; const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ + "PaymentRequestUpdateEvent", + "PaymentMethodChangeEvent", "AuthenticatorAssertionResponse", "AuthenticatorAttestationResponse", "AuthenticatorResponse", diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 569ef1c8cc..7df7c5801a 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -4,6 +4,14 @@ use crate::web_api_interfaces; use std::collections::HashSet; const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ + ConstructorSpec { + interface: web_api_interfaces::PaymentRequestUpdateEvent::DESCRIPTOR, + kind: ConstructorKind::PaymentRequestUpdateEvent, + }, + ConstructorSpec { + interface: web_api_interfaces::PaymentMethodChangeEvent::DESCRIPTOR, + kind: ConstructorKind::PaymentMethodChangeEvent, + }, ConstructorSpec { interface: web_api_interfaces::PictureInPictureEvent::DESCRIPTOR, kind: ConstructorKind::PictureInPictureEvent, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/types.rs b/moli-renderer-v8/src/context_bootstrap/specs/types.rs index 81aa49b066..730ce9e131 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/types.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/types.rs @@ -15,6 +15,8 @@ pub(in crate::context_bootstrap) enum ConstructorKind { ToolCancelEvent, GamepadEvent, PictureInPictureEvent, + PaymentRequestUpdateEvent, + PaymentMethodChangeEvent, UiEvent, FocusEvent, CompositionEvent, diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 9f893058d1..a7c9917b39 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2246,3 +2246,5 @@ mod dom_rect_structured_clone; mod domrect_receiver_consolidation; mod resize_observer_entries; + +mod payment_events; diff --git a/moli-renderer-v8/src/script_vm/tests/payment_events.js b/moli-renderer-v8/src/script_vm/tests/payment_events.js new file mode 100644 index 0000000000..c620fe4036 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/payment_events.js @@ -0,0 +1,100 @@ +(async function paymentEventChecks(realms) { + const checks = []; + const check = (name, passed) => checks.push({name, passed: Boolean(passed)}); + const throws = (name, action, Constructor, expectedName) => { + let error; + try { action(); } catch (caught) { error = caught; } + check(name, error instanceof Constructor && (!expectedName || error.name === expectedName)); + }; + for (let ownerIndex = 0; ownerIndex < realms.length; ownerIndex++) { + const owner = realms[ownerIndex]; + for (const name of ['PaymentRequestUpdateEvent', 'PaymentMethodChangeEvent']) { + const C = owner[name], p = C.prototype; + const prefix = ownerIndex + ':' + name + ':'; + check(prefix + 'constructor metadata', C.length === 1 && C.name === name); + const empty = new C('test'); + check(prefix + 'Event inheritance', empty instanceof owner.Event && empty instanceof owner.PaymentRequestUpdateEvent); + check(prefix + 'default event header', empty.type === 'test' && !empty.bubbles && !empty.cancelable && !empty.composed && !empty.isTrusted && empty.target === null); + for (const init of [undefined, null, {}]) { + const event = new C('test', init); + check(prefix + 'empty dictionary ' + String(init), !event.bubbles && !event.cancelable && !event.composed); + if (name === 'PaymentMethodChangeEvent') check(prefix + 'payload defaults ' + String(init), event.methodName === '' && event.methodDetails === null); + } + throws(prefix + 'requires new', () => C('test'), owner.TypeError); + throws(prefix + 'requires type', () => new C(), owner.TypeError); + const order = []; + const details = new Proxy({}, {get() {throw Error('payload object must not be read');}}); + const init = {get bubbles() {order.push('bubbles');return true;}, get cancelable() {order.push('cancelable');return true;}, get composed() {order.push('composed');return true;}, get methodDetails() {order.push('methodDetails');return details;}, get methodName() {order.push('methodName');return {toString() {order.push('name-string');return 'pay\ud800';}};}}; + const event = new C({toString() {order.push('type');return 'change\udc00';}}, init); + const expectedOrder = ['type', 'bubbles', 'cancelable', 'composed']; + if (name === 'PaymentMethodChangeEvent') expectedOrder.push('methodDetails', 'methodName', 'name-string'); + check(prefix + 'dictionary conversion order', order.join() === expectedOrder.join()); + check(prefix + 'UTF16 event type', event.type === 'change\udc00'); + check(prefix + 'event flags', event.bubbles && event.cancelable && event.composed && !event.isTrusted); + if (name === 'PaymentMethodChangeEvent') { + check(prefix + 'payload identity and UTF16', event.methodDetails === details && event.methodName === 'pay\ud800'); + for (const value of [undefined, null, {}, [], () => {}, new Proxy({}, {})]) { + const result = new C('x', {methodDetails: value}); + check(prefix + 'nullable object ' + String(typeof value), result.methodDetails === (value == null ? null : value)); + } + const revoked = Proxy.revocable({}, {}); revoked.revoke(); + check(prefix + 'revoked object payload retained', new C('x', {methodDetails: revoked.proxy}).methodDetails === revoked.proxy); + for (const value of [true, 1, 'x', Symbol('s'), 2n]) throws(prefix + 'reject primitive payload ' + typeof value, () => new C('x', {methodDetails: value}), owner.TypeError); + const token = {}; + let nameRead = false, caught; + try {new C('x', {get methodDetails() {throw token;}, get methodName() {nameRead = true;return 'x';}});} catch(error) {caught = error;} + check(prefix + 'dictionary exception and stop order', caught === token && !nameRead); + } + const target = new owner.EventTarget(); + const seen = []; + target.addEventListener(event.type, received => {seen.push(received === event && received.target === target && received.currentTarget === target);}); + target.dispatchEvent(event); + check(prefix + 'native EventTarget dispatch', seen.length === 1 && seen[0] && event.target === target && event.currentTarget === null && !event.isTrusted); + for (let calleeIndex = 0; calleeIndex < realms.length; calleeIndex++) { + const callee = realms[calleeIndex], q = callee.PaymentRequestUpdateEvent.prototype; + const label = prefix + 'callee-' + calleeIndex + ':'; + const update = q.updateWith; + const descriptor = Object.getOwnPropertyDescriptor(q, 'updateWith'); + check(label + 'operation descriptor', descriptor.enumerable && descriptor.configurable && descriptor.writable && update.length === 1 && update.name === 'updateWith'); + const invalid = [null, undefined, {}, Object.create(p), Object.create(event), new Proxy(event, {})]; + const revoked = Proxy.revocable(event, {}); revoked.revoke(); invalid.push(revoked.proxy); + for (let i = 0; i < invalid.length; i++) { + let reads = 0; + throws(label + 'reject receiver before conversion ' + i, () => update.call(invalid[i], {get then() {reads++;throw Error('conversion');}}), callee.TypeError); + check(label + 'no receiver conversion ' + i, reads === 0); + } + throws(label + 'missing argument', () => update.call(event), callee.TypeError); + for (const value of [undefined, null, {}, 1]) throws(label + 'untrusted value ' + typeof value, () => update.call(event, value), callee.DOMException, 'InvalidStateError'); + const calls = []; + const value = {get then() {calls.push('get');return resolve => {calls.push('call');resolve({});};}}; + throws(label + 'thenable update error', () => update.call(event, value), callee.DOMException, 'InvalidStateError'); + check(label + 'then getter before state check', calls.join() === 'get'); + await Promise.resolve(); await Promise.resolve(); + check(label + 'then invoked asynchronously', calls.join() === 'get,call'); + const nativeCalls = []; + const promise = owner.Promise.resolve({}); + Object.defineProperty(promise, 'then', {get() {nativeCalls.push('get');return resolve => {nativeCalls.push('call');resolve({});};}}); + Object.defineProperty(promise, 'constructor', {get() {throw Error('Promise constructor must not be read');}}); + throws(label + 'native promise update error', () => update.call(event, promise), callee.DOMException, 'InvalidStateError'); + check(label + 'WebIDL native Promise wrapping', nativeCalls.join() === 'get'); + await Promise.resolve(); await Promise.resolve(); + check(label + 'native Promise then invoked asynchronously', nativeCalls.join() === 'get,call'); + const delivered = new C('during'); + const dispatchOrder = []; + target.addEventListener('during', () => {throws(label + 'untrusted during dispatch', () => update.call(delivered, {}), callee.DOMException, 'InvalidStateError');dispatchOrder.push(1);}, {once:true}); + target.addEventListener('during', () => dispatchOrder.push(2), {once:true}); + target.dispatchEvent(delivered); + check(label + 'failed update leaves propagation intact', dispatchOrder.join() === '1,2'); + if (name === 'PaymentMethodChangeEvent') { + for (const key of ['methodName', 'methodDetails']) { + const desc = Object.getOwnPropertyDescriptor(callee.PaymentMethodChangeEvent.prototype, key); + check(label + key + ':accessor descriptor', desc.enumerable && desc.configurable && desc.set === undefined); + check(label + key + ':cross-realm getter', desc.get.call(event) === event[key]); + for (let i = 0; i < invalid.length; i++) throws(label + key + ':brand ' + i, () => desc.get.call(invalid[i]), callee.TypeError); + } + } + } + } + } + return checks; +}) diff --git a/moli-renderer-v8/src/script_vm/tests/payment_events.rs b/moli-renderer-v8/src/script_vm/tests/payment_events.rs new file mode 100644 index 0000000000..9f266cade4 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/payment_events.rs @@ -0,0 +1,81 @@ +use super::*; + +#[test] +fn payment_update_event_payloads_and_operations_use_native_cross_realm_brands() { + let mut vm = new_storage_page_task_executor_test_vm("https://payment-events.test/"); + vm.eval("document.body.innerHTML = ''") + .unwrap(); + let source = format!( + "({})([globalThis, document.getElementById('child').contentWindow]).then(checks => {{globalThis.__paymentChecks=checks;}})", + include_str!("payment_events.js"), + ); + vm.eval(&source).unwrap(); + assert_eq!(vm.eval("typeof __paymentChecks").unwrap(), "object"); + // Keep the native-Promise wrapping gaps explicit while the shared converter + // still preserves native Promise inputs. Every other check must pass. + let mut known_gaps = Vec::new(); + for owner in 0..2 { + for event in ["PaymentRequestUpdateEvent", "PaymentMethodChangeEvent"] { + for callee in 0..2 { + for check in [ + "WebIDL native Promise wrapping", + "native Promise then invoked asynchronously", + ] { + known_gaps.push(format!("{owner}:{event}:callee-{callee}:{check}")); + } + } + } + } + known_gaps.sort(); + assert_eq!( + vm.eval( + "JSON.stringify(__paymentChecks.filter(row => !row.passed).map(row => row.name).sort())" + ) + .unwrap(), + serde_json::to_string(&known_gaps).unwrap() + ); + assert_eq!(vm.eval("__paymentChecks.length").unwrap(), "378"); +} + +#[test] +fn payment_event_payloads_and_update_with_accept_registered_native_proxies() { + let mut vm = new_storage_page_task_executor_test_vm("https://payment-native-proxy.test/"); + vm.eval("globalThis.paymentEvent = new PaymentMethodChangeEvent('x', {methodName:'pay', methodDetails:{token:42}})") + .unwrap(); + let context_ptr = &vm.page_default_runtime.context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| { + let global = scope.get_current_context().global(scope); + let key = crate::util::v8str(scope, "paymentEvent"); + let event = + v8::Local::::try_from(global.get(scope, key.into()).unwrap()).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, event, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let key = crate::util::v8str(scope, "paymentProxy"); + assert_eq!( + global.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const p=PaymentMethodChangeEvent.prototype; + for(const key of ['methodName','methodDetails']) { + const get=Object.getOwnPropertyDescriptor(p,key).get; + if(get.call(paymentProxy)!==paymentEvent[key]) throw Error('native identity'); + let caught=false;try{get.call(new Proxy(paymentProxy,{}));}catch(error){caught=error instanceof TypeError;} + if(!caught) throw Error('author proxy accepted'); + } + let gets=0, error; + try{PaymentRequestUpdateEvent.prototype.updateWith.call(paymentProxy,{get then(){gets++;return undefined;}});}catch(caught){error=caught;} + if(gets!==1 || !(error instanceof DOMException) || error.name!=='InvalidStateError') throw Error('native proxy operation'); + return true; + })()"#).unwrap(), "true"); +} + +#[test] +fn payment_event_constructors_are_absent_in_insecure_window_realms() { + let mut vm = new_storage_page_task_executor_test_vm("http://payment-events.test/"); + assert_eq!(vm.eval("[isSecureContext, 'PaymentRequestUpdateEvent' in globalThis, 'PaymentMethodChangeEvent' in globalThis].join(',')").unwrap(), "false,false,false"); +} diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index d04e5b2528..79fc93865c 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -193,6 +193,8 @@ interfaces! { GamepadButton; GamepadEvent: Event; PictureInPictureEvent: Event; + PaymentRequestUpdateEvent: Event; + PaymentMethodChangeEvent: PaymentRequestUpdateEvent; PictureInPictureWindow: EventTarget; GamepadHapticActuator; HTMLAllCollection; diff --git a/moli-renderer-v8/src/worker/thread/tests/mod.rs b/moli-renderer-v8/src/worker/thread/tests/mod.rs index d9b2d05b46..63dc9aa0b8 100644 --- a/moli-renderer-v8/src/worker/thread/tests/mod.rs +++ b/moli-renderer-v8/src/worker/thread/tests/mod.rs @@ -1541,3 +1541,5 @@ mod tls; mod canvas_fill_rect; mod xhr_failure; + +mod payment_events; diff --git a/moli-renderer-v8/src/worker/thread/tests/payment_events.rs b/moli-renderer-v8/src/worker/thread/tests/payment_events.rs new file mode 100644 index 0000000000..07b8a3968e --- /dev/null +++ b/moli-renderer-v8/src/worker/thread/tests/payment_events.rs @@ -0,0 +1,16 @@ +use super::*; + +#[tokio::test] +async fn payment_update_event_interfaces_are_not_exposed_in_workers() { + ensure_v8(); + let mut worker = spawn_worker( + "postMessage([typeof PaymentRequestUpdateEvent, typeof PaymentMethodChangeEvent]);close();" + .into(), + "https://payment-worker.test/".into(), + ); + let message = timeout(TIMEOUT, worker.recv()).await.unwrap().unwrap(); + assert_eq!( + serde_json::from_str::(&expect_post_json(message)).unwrap(), + serde_json::json!(["undefined", "undefined"]) + ); +}