├── .prettierignore
├── .gitignore
├── .prettierrc.json
├── .github
├── dependabot.yml
└── workflows
│ ├── codeql-analysis.yml
│ └── ci.yml
├── frame.html
├── docs
├── SECURITY.md
├── contributing.md
└── code-of-conduct.md
├── tsconfig.json
├── frame_entry_point.ts
├── lib
├── shared
│ ├── version.ts
│ ├── guards.ts
│ ├── guards_test.ts
│ ├── messaging.ts
│ ├── api_types.ts
│ ├── messaging_test.ts
│ ├── protocol.ts
│ └── protocol_test.ts
├── package.json
├── connection.ts
├── connection_test.ts
└── public_api.ts
├── testing
├── storage.ts
├── error.ts
├── dom.ts
├── public_api_test.ts
├── public_api.ts
├── assert.ts
├── storage_test.ts
├── dom_test.ts
├── error_test.ts
├── assert_test.ts
├── messaging_test.ts
├── http_test.ts
├── messaging.ts
└── http.ts
├── webpack.config.js
├── package.json
├── frame
├── console_test.ts
├── console.ts
├── types.ts
├── main.ts
├── handler.ts
├── indexeddb.ts
├── indexeddb_test.ts
├── fetch.ts
├── db_schema.ts
├── main_test.ts
├── db_schema_test.ts
├── auction.ts
├── worklet.ts
└── handler_test.ts
├── karma.conf.js
├── fake_server.js
├── .eslintrc.js
├── README.md
└── LICENSE
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
2 | coverage/
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | coverage/
4 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | { "quoteProps": "preserve", "proseWrap": "always" }
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: /
5 | schedule:
6 | interval: daily
7 |
--------------------------------------------------------------------------------
/frame.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
Advertisement
8 |
9 |
--------------------------------------------------------------------------------
/docs/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | To report a security issue, please use http://g.co/vulnz. We use
6 | http://g.co/vulnz for our intake, and do coordination and disclosure here on
7 | GitHub (including using GitHub Security Advisory). The Google Security Team will
8 | respond within 5 working days of your report on g.co/vulnz.
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["dist/"],
3 | "compilerOptions": {
4 | "importHelpers": true,
5 | "sourceMap": true,
6 | "target": "ES2020",
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "moduleResolution": "Node",
10 | "forceConsistentCasingInFileNames": true,
11 | "skipLibCheck": true
12 | },
13 | "angularCompilerOptions": { "enableIvy": false }
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 | on:
3 | push:
4 | branches: [main]
5 | paths-ignore:
6 | - package.json
7 | - package-lock.json
8 | pull_request:
9 | branches: [main]
10 | schedule:
11 | - cron: 23 10 * * 1
12 | jobs:
13 | analyze:
14 | runs-on: ubuntu-latest
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 | steps:
20 | - uses: actions/checkout@v2
21 | - uses: github/codeql-action/init@v1
22 | with:
23 | languages: javascript
24 | - uses: github/codeql-action/analyze@v1
25 |
--------------------------------------------------------------------------------
/frame_entry_point.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview The entry point of execution for the JavaScript code inlined
9 | * in the frame.
10 | */
11 |
12 | import { main } from "./frame/main";
13 |
14 | const allowedLogicUrlPrefixesJoined = process.env.ALLOWED_LOGIC_URL_PREFIXES;
15 | // It shouldn't be possible for this to be undefined; Webpack will fail the
16 | // build if no value is provided.
17 | if (allowedLogicUrlPrefixesJoined === undefined) {
18 | throw new Error();
19 | }
20 | main(window, allowedLogicUrlPrefixesJoined);
21 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches: [main]
5 | pull_request:
6 | branches: [main]
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - uses: actions/cache@v2
13 | with:
14 | path: ~/.npm
15 | key: node-${{ hashFiles('**/package-lock.json') }}
16 | restore-keys: |
17 | node-
18 | - uses: actions/setup-node@v2
19 | with:
20 | node-version: "14"
21 | - run: npm ci
22 | - run: npm run check-format
23 | - run: npm run build
24 | env:
25 | ALLOWED_LOGIC_URL_PREFIXES:
26 | - run: npm run lint
27 | - run: npm test
28 |
--------------------------------------------------------------------------------
/lib/shared/version.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Versioning information to ensure that the library loads a frame
9 | * that's speaking the same version of the messaging protocol as itself.
10 | */
11 |
12 | /**
13 | * The current version of FLEDGE Shim. It's a good idea to include this in the
14 | * URL where the frame is hosted, and template it into `frameSrc`, so that when
15 | * the library is upgraded the frame remains in sync.
16 | */
17 | export const VERSION = "dev";
18 |
19 | /**
20 | * The name of the object property whose value is {@link VERSION} in the initial
21 | * handshake message.
22 | */
23 | export const VERSION_KEY = "fledgeShimVersion";
24 |
--------------------------------------------------------------------------------
/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fledge-shim",
3 | "version": "0.1.0",
4 | "description": "A pure-JavaScript implementation of the FLEDGE proposal.",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/google/fledge-shim.git"
8 | },
9 | "keywords": [
10 | "FLEDGE",
11 | "TURTLEDOVE",
12 | "Privacy Sandbox"
13 | ],
14 | "author": "Google LLC",
15 | "license": "Apache-2.0",
16 | "bugs": {
17 | "url": "https://github.com/google/fledge-shim/issues"
18 | },
19 | "homepage": "https://github.com/google/fledge-shim#readme",
20 | "dependencies": {
21 | "tslib": "^2.3.1"
22 | },
23 | "ngPackage": {
24 | "lib": {
25 | "entryFile": "public_api.ts"
26 | },
27 | "dest": "../dist/fledge-shim"
28 | },
29 | "publishConfig": {
30 | "registry": "https://wombat-dressing-room.appspot.com"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/testing/storage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * testing of code that interacts with client-side storage.
10 | */
11 |
12 | import "jasmine";
13 | import { useStore } from "../frame/indexeddb";
14 |
15 | /**
16 | * Completely empties everything out of IndexedDB and `sessionStorage` before
17 | * each test in the current suite, and again after the suite to prevent leakage.
18 | */
19 | export function clearStorageBeforeAndAfter(): void {
20 | beforeEach(clearStorage);
21 | afterAll(clearStorage);
22 | }
23 |
24 | async function clearStorage() {
25 | sessionStorage.clear();
26 | expect(
27 | await useStore("readwrite", (store) => {
28 | store.clear();
29 | })
30 | ).toBeTrue();
31 | }
32 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | const HtmlWebpackPlugin = require("html-webpack-plugin");
8 | const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin");
9 | const path = require("path");
10 | const { EnvironmentPlugin } = require("webpack");
11 |
12 | module.exports = {
13 | entry: path.resolve(__dirname, "frame_entry_point.ts"),
14 | mode: "production",
15 | module: { rules: [{ test: /\.ts$/, loader: "ts-loader" }] },
16 | resolve: { extensions: [".ts"] },
17 | plugins: [
18 | new EnvironmentPlugin(["ALLOWED_LOGIC_URL_PREFIXES"]),
19 | new HtmlWebpackPlugin({
20 | filename: "frame.html",
21 | template: path.resolve(__dirname, "frame.html"),
22 | scriptLoading: "blocking",
23 | }),
24 | new InlineChunkHtmlPlugin(HtmlWebpackPlugin, /* tests= */ [/.*/]),
25 | ],
26 | };
27 |
--------------------------------------------------------------------------------
/testing/error.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * testing what happens when an event handler throws.
10 | *
11 | * By default, Jasmine handles the global error event and fails the test, so we
12 | * we need to intercept it ourselves to avoid this.
13 | */
14 |
15 | /**
16 | * Causes Jasmine's default global error handler (which fails the current test)
17 | * to be restored after each test in the current `describe`. This allows those
18 | * tests to safely overwrite `onerror`, and thereby test behavior that occurs in
19 | * case of an unhandled error.
20 | *
21 | * This must be called directly within the callback body of a `describe`, before
22 | * any `it` calls.
23 | */
24 | export function restoreErrorHandlerAfterEach(): void {
25 | let errorHandler: typeof onerror;
26 | beforeEach(() => {
27 | errorHandler = onerror;
28 | });
29 | afterEach(() => {
30 | onerror = errorHandler;
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/testing/dom.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * testing of DOM operations.
10 | */
11 |
12 | import "jasmine";
13 |
14 | /**
15 | * Causes all tests in the current `describe` to use a clean DOM for each test.
16 | * Specifically, the state of the `` element is snapshotted by deep
17 | * cloning before each test, and then used to overwrite
18 | * `document.documentElement` afterward. This means that test cases can mutate
19 | * the DOM body, attach things to it, etc., without these state changes leaking
20 | * into other test cases.
21 | *
22 | * This must be called directly within the callback body of a `describe`, before
23 | * any `it` calls.
24 | */
25 | export function cleanDomAfterEach(): void {
26 | let documentElement: Node;
27 | beforeEach(() => {
28 | documentElement = document.documentElement.cloneNode(/* deep= */ true);
29 | });
30 | afterEach(() => {
31 | document.replaceChild(documentElement, document.documentElement);
32 | });
33 | }
34 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement. You (or your employer) retain the copyright to your contribution;
10 | this simply gives us permission to use and redistribute your contributions as
11 | part of the project. Head over to to see
12 | your current agreements on file or to sign a new one.
13 |
14 | You generally only need to submit a CLA once, so if you've already submitted one
15 | (even if it was for a different project), you probably don't need to do it
16 | again.
17 |
18 | ## Code Reviews
19 |
20 | All submissions, including submissions by project members, require review. We
21 | use GitHub pull requests for this purpose. Consult
22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
23 | information on using pull requests.
24 |
25 | ## Community Guidelines
26 |
27 | This project follows
28 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/).
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "build": "webpack && ng-packagr --project=./lib/package.json --config=./tsconfig.json",
5 | "check-format": "prettier --check .",
6 | "format": "prettier --write .",
7 | "lint": "eslint --max-warnings=0 .",
8 | "test": "karma start --single-run"
9 | },
10 | "dependencies": {
11 | "tslib": "^2.3.1"
12 | },
13 | "devDependencies": {
14 | "@angular/compiler": "^12.0.0",
15 | "@angular/compiler-cli": "^12.0.0",
16 | "@angular/core": "^13.1.1",
17 | "@types/jasmine": "^3.10.3",
18 | "@typescript-eslint/eslint-plugin": "^4.33.0",
19 | "@typescript-eslint/parser": "^4.33.0",
20 | "eslint": "^7.32.0",
21 | "eslint-config-google": "^0.14.0",
22 | "eslint-config-prettier": "^8.3.0",
23 | "eslint-plugin-jsdoc": "^37.6.1",
24 | "html-webpack-plugin": "^5.5.0",
25 | "jasmine-core": "^4.0.0",
26 | "karma": "^6.3.9",
27 | "karma-chrome-launcher": "^3.1.0",
28 | "karma-jasmine": "^4.0.1",
29 | "karma-typescript": "^5.5.2",
30 | "ng-packagr": "^12.2.6",
31 | "prettier": "2.5.1",
32 | "react-dev-utils": "^12.0.0",
33 | "ts-loader": "^9.2.6",
34 | "typescript": "^4.2.4",
35 | "webpack": "^5.65.0",
36 | "webpack-cli": "^4.9.1",
37 | "webpack-dev-middleware": "^5.3.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/frame/console_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import { logError, logWarning } from "./console";
8 |
9 | describe("logError", () => {
10 | it("should log an error without associated data", () => {
11 | const consoleSpy = spyOnAllFunctions(console);
12 | logError("Message");
13 | expect(consoleSpy.error).toHaveBeenCalledOnceWith("[FLEDGE Shim] Message");
14 | });
15 |
16 | it("should log an error with associated data", () => {
17 | const consoleSpy = spyOnAllFunctions(console);
18 | const data = Symbol();
19 | logError("Message", [data]);
20 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(
21 | "[FLEDGE Shim] Message",
22 | data
23 | );
24 | });
25 | });
26 |
27 | describe("logWarning", () => {
28 | it("should log a warning without associated data", () => {
29 | const consoleSpy = spyOnAllFunctions(console);
30 | logWarning("Message");
31 | expect(consoleSpy.warn).toHaveBeenCalledOnceWith("[FLEDGE Shim] Message");
32 | });
33 |
34 | it("should log a warning with associated data", () => {
35 | const consoleSpy = spyOnAllFunctions(console);
36 | const data = Symbol();
37 | logWarning("Message", [data]);
38 | expect(consoleSpy.warn).toHaveBeenCalledOnceWith(
39 | "[FLEDGE Shim] Message",
40 | data
41 | );
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/lib/shared/guards.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions for working around deficiencies in
9 | * TypeScript's flow-control-based type checking.
10 | */
11 |
12 | /**
13 | * Returns whether it's safe to access properties on this object (i.e., whether
14 | * doing so will throw). This should be used instead of an inline `!= null`
15 | * comparison in order to safely access arbitrary properties of an object with
16 | * unknown structure. If an inline comparison is used, TypeScript won't allow
17 | * this.
18 | */
19 | export function isObject(
20 | value: unknown
21 | ): value is { readonly [key: string]: unknown } {
22 | return value != null;
23 | }
24 |
25 | /**
26 | * Returns whether the given value is a plain-old-data object (i.e., not an
27 | * array or function or instance of some other type). This is intended for
28 | * runtime type checking of values parsed from JSON.
29 | */
30 | export function isKeyValueObject(
31 | value: unknown
32 | ): value is { readonly [key: string]: unknown } {
33 | return isObject(value) && Object.getPrototypeOf(value) === Object.prototype;
34 | }
35 |
36 | /**
37 | * Like `Array.isArray`, but returns `unknown[]` instead of `any[]`, avoiding
38 | * warnings.
39 | */
40 | export function isArray(value: unknown): value is readonly unknown[] {
41 | return Array.isArray(value);
42 | }
43 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | const webpack = require("webpack");
8 | const webpackDevMiddleware = require("webpack-dev-middleware");
9 |
10 | // This specifically affects the frame served by webpack-dev-middleware,
11 | // which lib/public_api_test.ts depends on.
12 | process.env.ALLOWED_LOGIC_URL_PREFIXES =
13 | "https://dsp.test/,https://dsp-1.test/,https://dsp-2.test/,https://ssp.test/";
14 |
15 | module.exports = (config) => {
16 | config.set({
17 | plugins: [
18 | "karma-*",
19 | {
20 | // Serves the compiled frame at /frame.html.
21 | "middleware:webpack-dev": [
22 | "factory",
23 | // https://github.com/karma-runner/karma/issues/2781
24 | function () {
25 | return webpackDevMiddleware(
26 | webpack(require("./webpack.config.js"))
27 | );
28 | },
29 | ],
30 | },
31 | ],
32 | frameworks: ["jasmine", "karma-typescript"],
33 | files: [
34 | "frame/**/*.ts",
35 | "lib/**/*.ts",
36 | "testing/**/*.ts",
37 | { pattern: "fake_server.js", included: false },
38 | ],
39 | middleware: ["webpack-dev"],
40 | preprocessors: { "**/*.ts": "karma-typescript" },
41 | proxies: { "/fake_server.js": "/base/fake_server.js" },
42 | reporters: ["progress", "karma-typescript"],
43 | browsers: ["ChromeHeadless"],
44 | karmaTypescriptConfig: {
45 | compilerOptions: { module: "commonjs" },
46 | tsconfig: "./tsconfig.json",
47 | },
48 | });
49 | };
50 |
--------------------------------------------------------------------------------
/frame/console.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utilities for logging information to the console in the frame,
9 | * for the benefit of developers trying to debug FLEDGE Shim or their
10 | * integrations with it.
11 | *
12 | * In general, these APIs are used whenever the frame encounters an I/O error
13 | * (including receiving invalid data) when communicating with something outside
14 | * its direct control, such as the parent page, IndexedDB, or a URL where data
15 | * is to be fetched from. Nothing is logged in the success path, and errors
16 | * caused by bugs in the FLEDGE Shim frame itself are thrown as exceptions.
17 | * (Integration bugs between the frame and the library can result in these kinds
18 | * of logs, though, because the two components cannot trust one another.) In
19 | * order to preserve confidentiality of FLEDGE Shim data, error messages from
20 | * the frame are not exposed to the library; developers are instead advised to
21 | * inspect the console logs.
22 | */
23 |
24 | const prefix = "[FLEDGE Shim] ";
25 |
26 | /**
27 | * Logs a console error. Used when an error occurs that makes it impossible to
28 | * continue with the current operation.
29 | */
30 | export function logError(message: string, data?: readonly unknown[]): void {
31 | console.error(prefix + message, ...(data ?? []));
32 | }
33 |
34 | /**
35 | * Logs a console warning. Used when an error occurs that can be recovered from.
36 | */
37 | export function logWarning(message: string, data?: readonly unknown[]): void {
38 | console.warn(prefix + message, ...(data ?? []));
39 | }
40 |
--------------------------------------------------------------------------------
/testing/public_api_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { FledgeShim } from "../lib/public_api";
9 | import { assertToBeInstanceOf } from "./assert";
10 | import { create, renderUrlFromAuctionResult } from "./public_api";
11 | import { clearStorageBeforeAndAfter } from "./storage";
12 |
13 | describe("create", () => {
14 | let fledgeShim: FledgeShim;
15 | it("should return a FledgeShim", () => {
16 | fledgeShim = create();
17 | expect(fledgeShim).toBeInstanceOf(FledgeShim);
18 | });
19 | afterAll(() => {
20 | expect(fledgeShim.isDestroyed()).toBeTrue();
21 | });
22 |
23 | it("should not try to re-destroy an already destroyed FledgeShim", () => {
24 | create().destroy();
25 | expect().nothing();
26 | });
27 | });
28 |
29 | describe("renderUrlFromAuctionResult", () => {
30 | clearStorageBeforeAndAfter();
31 |
32 | const token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
33 | const renderUrl = "about:blank#ad";
34 |
35 | it("should return the rendering URL from an auction result", async () => {
36 | sessionStorage.setItem(token, renderUrl);
37 | expect(await renderUrlFromAuctionResult("/frame.html#" + token)).toBe(
38 | renderUrl
39 | );
40 | });
41 |
42 | it("should clean up after itself", async () => {
43 | sessionStorage.setItem(token, renderUrl);
44 | const existingDom = document.documentElement.cloneNode(/* deep= */ true);
45 | assertToBeInstanceOf(existingDom, HTMLHtmlElement);
46 | await renderUrlFromAuctionResult("/frame.html#" + token);
47 | expect(document.documentElement).toEqual(existingDom);
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/lib/shared/guards_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { isArray, isKeyValueObject, isObject } from "./guards";
9 |
10 | describe("isObject", () => {
11 | it("should return true for {}", () => {
12 | expect(isObject({})).toBeTrue();
13 | });
14 | it("should return true for []", () => {
15 | expect(isObject([])).toBeTrue();
16 | });
17 | it("should return true for function", () => {
18 | expect(isObject(() => null)).toBeTrue();
19 | });
20 | it("should return true for Date", () => {
21 | expect(isObject(new Date())).toBeTrue();
22 | });
23 | it("should return false for null", () => {
24 | expect(isObject(null)).toBeFalse();
25 | });
26 | it("should return false for undefined", () => {
27 | expect(isObject(undefined)).toBeFalse();
28 | });
29 | });
30 |
31 | describe("isKeyValueObject", () => {
32 | it("should return true for {}", () => {
33 | expect(isKeyValueObject({})).toBeTrue();
34 | });
35 | it("should return false for []", () => {
36 | expect(isKeyValueObject([])).toBeFalse();
37 | });
38 | it("should return false for function", () => {
39 | expect(isKeyValueObject(() => null)).toBeFalse();
40 | });
41 | it("should return false for Date", () => {
42 | expect(isKeyValueObject(new Date())).toBeFalse();
43 | });
44 | it("should return false for null", () => {
45 | expect(isKeyValueObject(null)).toBeFalse();
46 | });
47 | it("should return false for undefined", () => {
48 | expect(isKeyValueObject(undefined)).toBeFalse();
49 | });
50 | });
51 |
52 | describe("isArray", () => {
53 | it("should return true for []", () => {
54 | expect(isArray([])).toBeTrue();
55 | });
56 | it("should return false for {}}", () => {
57 | expect(isArray({})).toBeFalse();
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/frame/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Type definitions that span multiple concerns within the frame.
9 | */
10 |
11 | import { AuctionAd } from "../lib/shared/api_types";
12 |
13 | /**
14 | * Analogous to `InterestGroup` from `../lib/shared/api_types`, but all fields
15 | * are required and metadata is represented as serialized JSON. This represents
16 | * an interest group as it is stored and used internally.
17 | */
18 | export interface CanonicalInterestGroup {
19 | name: string;
20 | biddingLogicUrl: string | undefined;
21 | trustedBiddingSignalsUrl: string | undefined;
22 | ads: AuctionAd[];
23 | }
24 |
25 | /**
26 | * A bid returned by `generateBid` in a bidding script.
27 | *
28 | * @see https://github.com/WICG/turtledove/blob/main/FLEDGE.md#32-on-device-bidding
29 | */
30 | export interface BidData {
31 | /**
32 | * The JSON serialization of an arbitrary metadata value provided by the
33 | * bidding script in association with its bid. This is passed to the scoring
34 | * script.
35 | */
36 | adJson: string;
37 | /**
38 | * The amount that the buyer is willing to pay in order to have this ad
39 | * selected. This is passed to the scoring script.
40 | *
41 | * The precise meaning of this value (what currency it's in, CPM vs. CPC,
42 | * etc.) is a matter of convention among buyers and sellers. The current
43 | * implementation requires the entire ecosystem to adopt a uniform
44 | * convention, which is impractical, but future implementations will allow
45 | * sellers to choose which buyers to transact with, which will allow them to
46 | * deal only with buyers with whom they've made out-of-band agreements that
47 | * specify the meaning.
48 | */
49 | bid: number;
50 | /**
51 | * The URL where the actual creative is hosted. This will be used as the `src`
52 | * of an iframe that will appear on the page if this ad wins.
53 | */
54 | render: string;
55 | }
56 |
--------------------------------------------------------------------------------
/testing/public_api.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * testing of the FledgeShim public API with proper cleanup.
10 | */
11 |
12 | import "jasmine";
13 | import { FledgeShim } from "../lib/public_api";
14 | import { assertToBeTruthy } from "./assert";
15 |
16 | let fledgeShims: FledgeShim[] = [];
17 |
18 | /**
19 | * Returns a newly constructed FledgeShim using a frame served from Karma, that
20 | * will be destroyed after the current test.
21 | */
22 | export function create(): FledgeShim {
23 | const fledgeShim = new FledgeShim("/frame.html");
24 | fledgeShims.push(fledgeShim);
25 | return fledgeShim;
26 | }
27 |
28 | afterEach(() => {
29 | for (const fledgeShim of fledgeShims) {
30 | if (!fledgeShim.isDestroyed()) {
31 | fledgeShim.destroy();
32 | }
33 | }
34 | fledgeShims = [];
35 | });
36 |
37 | /**
38 | * Given a string returned from `runAdAuction`, returns the `renderUrl` of the
39 | * winning ad. Note that this is only possible because the frame served by Karma
40 | * is same-origin to the page where the tests run; browser-native
41 | * implementations will not allow this, nor is it possible in production with
42 | * the shim when using a frame on a different origin from the publisher page.
43 | */
44 | export async function renderUrlFromAuctionResult(
45 | auctionResultUrl: string
46 | ): Promise {
47 | const outerIframe = document.createElement("iframe");
48 | outerIframe.src = auctionResultUrl;
49 | const loadPromise = new Promise((resolve) => {
50 | outerIframe.addEventListener("load", resolve, { once: true });
51 | });
52 | document.body.appendChild(outerIframe);
53 | try {
54 | await loadPromise;
55 | assertToBeTruthy(outerIframe.contentDocument);
56 | const innerIframe = outerIframe.contentDocument.querySelector("iframe");
57 | assertToBeTruthy(innerIframe);
58 | return innerIframe.src;
59 | } finally {
60 | outerIframe.remove();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/testing/assert.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * the use of TypeScript type narrowing in Jasmine tests.
10 | *
11 | * These are like Jasmine `expect`, but throw if the expectations are not met,
12 | * instead of recording a failure. This allows them to serve as TypeScript type
13 | * guards.
14 | */
15 |
16 | import "jasmine";
17 | import { isObject } from "../lib/shared/guards";
18 |
19 | /** Asserts that the given value is truthy. Throws otherwise. */
20 | export function assertToBeTruthy(actual: unknown): asserts actual {
21 | if (!actual) {
22 | throw new TypeError(`Expected ${String(actual)} to be truthy`);
23 | }
24 | }
25 |
26 | /** Asserts that the given value is a string. Throws otherwise. */
27 | export function assertToBeString(actual: unknown): asserts actual is string {
28 | if (typeof actual !== "string") {
29 | throw new TypeError(`Expected ${typeDescription(actual)} to be a string`);
30 | }
31 | }
32 |
33 | /**
34 | * Asserts that the given value is an instance of the given constructor's class.
35 | * Throws otherwise.
36 | */
37 | export function assertToBeInstanceOf(
38 | actual: unknown,
39 | ctor: abstract new (...args: never[]) => T
40 | ): asserts actual is T {
41 | if (!(actual instanceof ctor)) {
42 | throw new TypeError(
43 | `Expected ${typeDescription(actual)} to be an instance of ${ctor.name}`
44 | );
45 | }
46 | }
47 |
48 | /**
49 | * Asserts that the given type guard function is true of the given value. Throws
50 | * otherwise.
51 | */
52 | export function assertToSatisfyTypeGuard(
53 | actual: unknown,
54 | guard: (value: unknown) => value is T
55 | ): asserts actual is T {
56 | if (!guard(actual)) {
57 | throw new TypeError(
58 | `Expected ${typeDescription(actual)} to satisfy ${guard.name}`
59 | );
60 | }
61 | }
62 |
63 | function typeDescription(value: unknown) {
64 | return isObject(value)
65 | ? `instance of ${value.constructor.name}`
66 | : String(value);
67 | }
68 |
--------------------------------------------------------------------------------
/testing/storage_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { useStore } from "../frame/indexeddb";
9 | import { clearStorageBeforeAndAfter } from "./storage";
10 |
11 | describe("clearStorageBeforeAndAfter", () => {
12 | describe("with sessionStorage", () => {
13 | clearStorageBeforeAndAfter();
14 | for (const nth of ["first", "second"]) {
15 | it(`should not already contain item when adding it (${nth} time)`, () => {
16 | expect(sessionStorage.length).toBe(0);
17 | const key = "sessionStorage key";
18 | const value = "sessionStorage value";
19 | sessionStorage.setItem(key, value);
20 | expect(sessionStorage.getItem(key)).toBe(value);
21 | });
22 | }
23 | });
24 |
25 | describe("with IndexedDB", () => {
26 | clearStorageBeforeAndAfter();
27 | for (const nth of ["first", "second"]) {
28 | it(`should not already contain item when adding it (${nth} time)`, async () => {
29 | const value = "IndexedDB value";
30 | let retrieved: unknown;
31 | expect(
32 | await useStore("readwrite", (store) => {
33 | const countRequest = store.count();
34 | countRequest.onsuccess = () => {
35 | expect(countRequest.result).toBe(0);
36 | const key = "IndexedDB key";
37 | store.add(value, key).onsuccess = () => {
38 | const retrievalRequest = store.get(key);
39 | retrievalRequest.onsuccess = () => {
40 | retrieved = retrievalRequest.result;
41 | };
42 | };
43 | };
44 | })
45 | ).toBeTrue();
46 | expect(retrieved).toBe(value);
47 | });
48 | }
49 | });
50 |
51 | describe("with beforeEach", () => {
52 | clearStorageBeforeAndAfter();
53 | const key = "sessionStorage key from beforeEach test";
54 | const value = "sessionStorage value from beforeEach test";
55 | beforeEach(() => {
56 | sessionStorage.setItem(key, value);
57 | });
58 | it("should have stored an item in beforeEach", () => {
59 | expect(sessionStorage.getItem(key)).toBe(value);
60 | });
61 | });
62 |
63 | describe("with afterEach", () => {
64 | clearStorageBeforeAndAfter();
65 | const key = "sessionStorage key from afterEach test";
66 | const value = "sessionStorage value from afterEach test";
67 | afterEach(() => {
68 | expect(sessionStorage.getItem(key)).toBe(value);
69 | });
70 | it("stores an item", () => {
71 | sessionStorage.setItem(key, value);
72 | expect().nothing();
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/testing/dom_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { cleanDomAfterEach } from "./dom";
9 |
10 | const ID = "clean-dom-after-each-child";
11 |
12 | describe("cleanDomAfterEach", () => {
13 | describe("with child in head", () => {
14 | cleanDomAfterEach();
15 | for (const nth of ["first", "second"]) {
16 | it(`should not already be present when adding it (${nth} time)`, () => {
17 | expect(document.getElementById(ID)).toBeNull();
18 | const child = document.createElement("style");
19 | child.id = ID;
20 | document.head.appendChild(child);
21 | expect(document.getElementById(ID)).toBe(child);
22 | expect(child.parentNode).toBe(document.head);
23 | });
24 | }
25 | });
26 |
27 | describe("with child in body", () => {
28 | cleanDomAfterEach();
29 | for (const nth of ["first", "second"]) {
30 | it(`should not already be present when adding it (${nth} time)`, () => {
31 | expect(document.getElementById(ID)).toBeNull();
32 | const child = document.createElement("div");
33 | child.id = ID;
34 | document.body.appendChild(child);
35 | expect(document.getElementById(ID)).toBe(child);
36 | expect(child.parentNode).toBe(document.body);
37 | });
38 | }
39 | });
40 |
41 | describe("with attribute on DOM root", () => {
42 | cleanDomAfterEach();
43 | for (const nth of ["first", "second"]) {
44 | it(`should not already be present when adding it (${nth} time)`, () => {
45 | expect("cleanDomAfterEachAttribute" in document.documentElement.dataset)
46 | .withContext(
47 | `Element should not have data-clean-dom-after-each-attribute=${
48 | document.documentElement.dataset["cleanDomAfterEachAttribute"] ??
49 | ""
50 | }`
51 | )
52 | .toBeFalse();
53 | document.documentElement.dataset["cleanDomAfterEachAttribute"] =
54 | "value";
55 | });
56 | }
57 | });
58 |
59 | describe("with beforeEach", () => {
60 | cleanDomAfterEach();
61 | let child: HTMLDivElement;
62 | beforeEach(() => {
63 | child = document.createElement("div");
64 | child.id = ID;
65 | document.body.appendChild(child);
66 | });
67 | it("should have added it to the DOM body in beforeEach", () => {
68 | expect(document.getElementById(ID)).toBe(child);
69 | });
70 | });
71 |
72 | describe("with afterEach", () => {
73 | cleanDomAfterEach();
74 | let child: HTMLDivElement;
75 | afterEach(() => {
76 | expect(document.getElementById(ID)).toBe(child);
77 | });
78 | it("adds it to the DOM body", () => {
79 | child = document.createElement("div");
80 | child.id = ID;
81 | document.body.appendChild(child);
82 | expect().nothing();
83 | });
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/fake_server.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /** @fileoverview Service Worker installed by `./lib/shared/testing/http`. */
8 |
9 | onactivate = (event) => {
10 | // The service worker is installed by the Karma test window at the beginning
11 | // of the tests. We want it to take control of that window so that it can
12 | // intercept subsequent fetches made in the tests.
13 | event.waitUntil(clients.claim());
14 | };
15 |
16 | let port;
17 |
18 | onmessage = (messageEvent) => {
19 | [port] = messageEvent.ports;
20 | // If the browser puts the service worker to sleep, then on the next fetch
21 | // this whole script will be reevaluated and the value assigned to port will
22 | // no longer be present. We can't let that happen; we need a consistent port
23 | // throughout the tests. So we leave an event pending until the test sends a
24 | // message to explicitly indicate that it's done running; this ensures that
25 | // the browser will keep the service worker awake.
26 | messageEvent.waitUntil(
27 | new Promise((resolve) => {
28 | port.onmessage = resolve;
29 | })
30 | );
31 | // Tell the test code that the port has been set and so we're now ready to
32 | // receive and handle fetches.
33 | port.postMessage(null);
34 | };
35 |
36 | onfetch = async (fetchEvent) => {
37 | const { url, method, headers, credentials } = fetchEvent.request;
38 | if (!new URL(url).hostname.endsWith(".test")) {
39 | return;
40 | }
41 | const { port1: receiver, port2: sender } = new MessageChannel();
42 | fetchEvent.respondWith(
43 | new Promise((resolve, reject) => {
44 | receiver.onmessage = ({ data }) => {
45 | receiver.close();
46 | if (!data) {
47 | reject();
48 | return;
49 | }
50 | let [status, statusText, headers, body] = data;
51 | if (body === null) {
52 | // Cause any attempt to read the body to reject.
53 | body = new ReadableStream({
54 | start(controller) {
55 | controller.error();
56 | },
57 | });
58 | }
59 | const response = new Response(body, { status, statusText });
60 | // The browser adds this by default when constructing a response with a
61 | // body, but we want there to be no Content-Type header at all if one
62 | // wasn't explicitly added, since this is how real servers are treated.
63 | response.headers.delete("Content-Type");
64 | for (const [name, value] of Object.entries(headers ?? {})) {
65 | response.headers.append(name, value);
66 | }
67 | resolve(response);
68 | };
69 | })
70 | );
71 | port.postMessage(
72 | [
73 | url,
74 | method,
75 | [...headers.entries()],
76 | await fetchEvent.request.arrayBuffer(),
77 | credentials === "include",
78 | ],
79 | [sender]
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/lib/shared/messaging.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /** @fileoverview Utility functions for dealing with `postMessage`. */
8 |
9 | /**
10 | * Returns a promise that resolves to the first `MessageEvent` sent from
11 | * `iframe`'s content window to the current window, or to null if a
12 | * deserialization error occurs.
13 | */
14 | export function awaitMessageFromIframeToSelf(
15 | iframe: HTMLIFrameElement
16 | ): Promise | null> {
17 | return awaitMessage(window, ({ source }) => source === iframe.contentWindow);
18 | }
19 |
20 | /**
21 | * Returns a promise that resolves to the first `MessageEvent` sent from the
22 | * current window to itself, or to null if a deserialization error occurs.
23 | */
24 | export function awaitMessageFromSelfToSelf(): Promise | null> {
25 | return awaitMessage(window, ({ source }) => source === window);
26 | }
27 |
28 | /**
29 | * Returns a promise that resolves to the first `MessageEvent` sent to `port`,
30 | * which is activated if it hasn't been already, or to null if a deserialization
31 | * error occurs. Closes the port afterwards.
32 | */
33 | export async function awaitMessageToPort(
34 | port: MessagePort
35 | ): Promise | null> {
36 | try {
37 | const messageEventPromise = awaitMessage(port, () => true);
38 | port.start();
39 | return await messageEventPromise;
40 | } finally {
41 | port.close();
42 | }
43 | }
44 |
45 | /**
46 | * Returns a promise that resolves to the first `MessageEvent` sent to `worker`
47 | * via `postMessage` in `worker`'s global scope, or to null if a deserialization
48 | * error occurs.
49 | */
50 | export function awaitMessageToWorker(
51 | worker: Worker
52 | ): Promise | null> {
53 | return awaitMessage(worker, () => true);
54 | }
55 |
56 | function awaitMessage(
57 | target: MessageTarget,
58 | filter: (event: MessageEvent) => boolean
59 | ) {
60 | return new Promise | null>((resolve) => {
61 | const messageListener = (event: MessageEvent) => {
62 | if (filter(event)) {
63 | target.removeEventListener("message", messageListener);
64 | target.removeEventListener("messageerror", messageErrorListener);
65 | resolve(event);
66 | }
67 | };
68 | const messageErrorListener = (event: MessageEvent) => {
69 | if (filter(event)) {
70 | target.removeEventListener("message", messageListener);
71 | target.removeEventListener("messageerror", messageErrorListener);
72 | resolve(null);
73 | }
74 | };
75 | target.addEventListener("message", messageListener);
76 | target.addEventListener("messageerror", messageErrorListener);
77 | });
78 | }
79 |
80 | declare interface MessageTarget {
81 | addEventListener(
82 | type: "message" | "messageerror",
83 | listener: (event: MessageEvent) => void
84 | ): void;
85 | removeEventListener(
86 | type: "message" | "messageerror",
87 | listener: (event: MessageEvent) => void
88 | ): void;
89 | }
90 |
--------------------------------------------------------------------------------
/testing/error_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { assertToBeTruthy } from "./assert";
9 | import { restoreErrorHandlerAfterEach } from "./error";
10 |
11 | describe("restoreErrorHandlerAfterEach", () => {
12 | describe("with custom onerror", () => {
13 | restoreErrorHandlerAfterEach();
14 | for (const nth of ["first", "second"]) {
15 | it(`should not already be present when setting it (${nth} time)`, async () => {
16 | assertToBeTruthy(onerror);
17 | expect(jasmine.isSpy(onerror)).toBeFalse();
18 | let errorHandler;
19 | const error = new Error("oops");
20 | await new Promise((resolve) => {
21 | errorHandler = onerror = jasmine
22 | .createSpy("onerror")
23 | .and.callFake(resolve);
24 | setTimeout(() => {
25 | throw error;
26 | }, 0);
27 | });
28 | expect(errorHandler).toHaveBeenCalledOnceWith(
29 | "Uncaught Error: oops",
30 | jasmine.any(String),
31 | jasmine.any(Number),
32 | jasmine.any(Number),
33 | error
34 | );
35 | });
36 | }
37 | });
38 |
39 | describe("with beforeEach", () => {
40 | restoreErrorHandlerAfterEach();
41 | let errorHandler: jasmine.Spy;
42 | let errorPromise: Promise;
43 | beforeEach(() => {
44 | errorPromise = new Promise((resolve) => {
45 | errorHandler = onerror = jasmine
46 | .createSpy("onerror")
47 | .and.callFake(() => {
48 | resolve();
49 | });
50 | });
51 | });
52 | it("should have set onerror in beforeEach", async () => {
53 | const error = new Error("oops");
54 | setTimeout(() => {
55 | throw error;
56 | }, 0);
57 | await errorPromise;
58 | expect(errorHandler).toHaveBeenCalledOnceWith(
59 | "Uncaught Error: oops",
60 | jasmine.any(String),
61 | jasmine.any(Number),
62 | jasmine.any(Number),
63 | error
64 | );
65 | });
66 | });
67 |
68 | describe("with afterEach", () => {
69 | restoreErrorHandlerAfterEach();
70 | let errorHandler: jasmine.Spy;
71 | let errorPromise: Promise;
72 | afterEach(async () => {
73 | const error = new Error("oops");
74 | setTimeout(() => {
75 | throw error;
76 | }, 0);
77 | await errorPromise;
78 | expect(errorHandler).toHaveBeenCalledOnceWith(
79 | "Uncaught Error: oops",
80 | jasmine.any(String),
81 | jasmine.any(Number),
82 | jasmine.any(Number),
83 | error
84 | );
85 | });
86 | it("sets onerror", () => {
87 | errorPromise = new Promise((resolve) => {
88 | errorHandler = onerror = jasmine
89 | .createSpy("onerror")
90 | .and.callFake(() => {
91 | resolve();
92 | });
93 | });
94 | expect().nothing();
95 | });
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/frame/main.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview The logic immediately called by the entry-point file, factored
9 | * out into a separate file for testing purposes.
10 | */
11 |
12 | import { VERSION, VERSION_KEY } from "../lib/shared/version";
13 | import { logError } from "./console";
14 | import { handleRequest } from "./handler";
15 |
16 | /**
17 | * Runs the frame code.
18 | *
19 | * @param win The window whose parent the handshake message is sent to or that
20 | * the ad is rendered into. This window's location fragment is used to decide
21 | * what to do. In production, this is always the global `window` object; a
22 | * friendly iframe may be used in unit tests.
23 | * @param allowedLogicUrlPrefixesJoined URL prefixes that worklet scripts are
24 | * to be sourced from, separated by commas.
25 | */
26 | export function main(win: Window, allowedLogicUrlPrefixesJoined: string): void {
27 | const allowedLogicUrlPrefixes = allowedLogicUrlPrefixesJoined.split(",");
28 | for (const prefix of allowedLogicUrlPrefixes) {
29 | let url;
30 | try {
31 | url = new URL(prefix);
32 | } catch (error: unknown) {
33 | /* istanbul ignore else */
34 | if (error instanceof TypeError) {
35 | logError("Prefix must be a valid absolute URL:", [prefix]);
36 | return;
37 | } else {
38 | throw error;
39 | }
40 | }
41 | if (!prefix.endsWith("/")) {
42 | logError("Prefix must end with a slash:", [prefix]);
43 | return;
44 | }
45 | if (url.protocol !== "https:") {
46 | logError("Prefix must be HTTPS:", [prefix]);
47 | return;
48 | }
49 | }
50 | const parentOrigin = win.location.ancestorOrigins[0];
51 | if (parentOrigin === undefined) {
52 | logError("Frame can't run as a top-level document");
53 | return;
54 | }
55 | const fragment = win.location.hash;
56 | if (fragment) {
57 | render(win.document, fragment);
58 | } else {
59 | connect(win.parent, parentOrigin, allowedLogicUrlPrefixes);
60 | }
61 | }
62 |
63 | function connect(
64 | targetWindow: Window,
65 | targetOrigin: string,
66 | allowedLogicUrlPrefixes: readonly string[]
67 | ) {
68 | const { port1: receiver, port2: sender } = new MessageChannel();
69 | const { hostname } = new URL(targetOrigin);
70 | receiver.onmessage = (event: MessageEvent) => {
71 | void handleRequest(event, hostname, allowedLogicUrlPrefixes);
72 | };
73 | targetWindow.postMessage({ [VERSION_KEY]: VERSION }, targetOrigin, [sender]);
74 | }
75 |
76 | function render(doc: Document, fragment: string) {
77 | const renderUrl = sessionStorage.getItem(fragment.substring(1));
78 | if (!renderUrl) {
79 | logError("Invalid token:", [fragment]);
80 | return;
81 | }
82 | const iframe = doc.createElement("iframe");
83 | iframe.src = renderUrl;
84 | iframe.scrolling = "no";
85 | iframe.style.border = "none";
86 | iframe.style.width =
87 | iframe.style.height =
88 | doc.body.style.height =
89 | doc.documentElement.style.height =
90 | "100%";
91 | doc.body.style.margin = "0";
92 | doc.body.appendChild(iframe);
93 | }
94 |
--------------------------------------------------------------------------------
/lib/shared/api_types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Type definitions used both in the public API and internally in
9 | * implementation code.
10 | */
11 |
12 | /**
13 | * An ad creative that can participate in an auction and later be rendered onto
14 | * the page if it wins.
15 | *
16 | * The properties of this type aren't actually specified in the FLEDGE explainer
17 | * at present; they are our best guess as to how this will work, and may be
18 | * replaced later with a different API.
19 | */
20 | export interface AuctionAd {
21 | /**
22 | * The URL where the actual creative is hosted. This will be used as the `src`
23 | * of an iframe that will appear on the page if this ad wins.
24 | */
25 | renderUrl: string;
26 | /** Additional metadata about this ad that can be read by the auction. */
27 | // eslint-disable-next-line @typescript-eslint/ban-types
28 | metadata?: object;
29 | }
30 |
31 | /**
32 | * A collection of ad creatives, with associated metadata.
33 | *
34 | * @see https://github.com/WICG/turtledove/blob/main/FLEDGE.md#1-browsers-record-interest-groups
35 | */
36 | export interface AuctionAdInterestGroup {
37 | /**
38 | * A name that uniquely identifies this interest group within the browser,
39 | * that can be used to refer to it in order to update or delete it later.
40 | */
41 | name: string;
42 | /**
43 | * An HTTPS URL. At auction time, the bidding script is fetched from here and
44 | * its `generateBid` function is called in an isolated worklet-like
45 | * environment. The script must be served with a JavaScript MIME type and with
46 | * the header `X-FLEDGE-Shim: true`, and its URL must begin with one of the
47 | * prefixes specified when building the frame. If undefined, this interest
48 | * group is silently skipped.
49 | */
50 | biddingLogicUrl?: string;
51 | /**
52 | * An HTTPS URL with no query string. If provided, a request to this URL is
53 | * made at auction time. The response is expected to be a JSON object.
54 | */
55 | trustedBiddingSignalsUrl?: string;
56 | /**
57 | * Ads to be entered into the auction for impressions that this interest group
58 | * is permitted to bid on.
59 | */
60 | ads?: AuctionAd[];
61 | }
62 |
63 | /**
64 | * Parameters for running an auction, specified by the seller.
65 | *
66 | * @see https://github.com/WICG/turtledove/blob/main/FLEDGE.md#2-sellers-run-on-device-auctions
67 | */
68 | export interface AuctionAdConfig {
69 | /**
70 | * An HTTPS URL. At auction time, the auction script is fetched from here and
71 | * its `scoreAd` function is called in an isolated worklet-like environment.
72 | * The script must be served with a JavaScript MIME type and with the header
73 | * `X-FLEDGE-Shim: true`, and its URL must begin with one of the prefixes
74 | * specified when building the frame. If undefined, the entire auction is
75 | * silently skipped.
76 | */
77 | decisionLogicUrl: string;
78 | /**
79 | * An HTTPS URL with no query string. If provided, a request to this URL is
80 | * made at auction time. The response is expected to be a JSON object.
81 | *
82 | * @see https://github.com/WICG/turtledove/blob/main/FLEDGE.md#31-fetching-real-time-data-from-a-trusted-server
83 | */
84 | trustedScoringSignalsUrl?: string;
85 | }
86 |
--------------------------------------------------------------------------------
/frame/handler.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Dispatcher that does basic validation on requests and then
9 | * forwards them to the appropriate function.
10 | */
11 |
12 | import {
13 | requestFromMessageData,
14 | RequestKind,
15 | RunAdAuctionResponse,
16 | } from "../lib/shared/protocol";
17 | import { runAdAuction } from "./auction";
18 | import { logError } from "./console";
19 | import { deleteInterestGroup, storeInterestGroup } from "./db_schema";
20 |
21 | /**
22 | * Handles a `MessageEvent` representing a request to the FLEDGE API, and sends
23 | * a response via the provided ports if needed.
24 | *
25 | * If an error occurs, a message is sent to each provided port so that the
26 | * caller doesn't hang.
27 | *
28 | * @param hostname The hostname of the page where the FLEDGE Shim API is
29 | * running.
30 | * @param allowedLogicUrlPrefixes URL prefixes that worklet scripts are allowed
31 | * to be sourced from.
32 | */
33 | export async function handleRequest(
34 | { data, ports }: MessageEvent,
35 | hostname: string,
36 | allowedLogicUrlPrefixes: readonly string[]
37 | ): Promise {
38 | try {
39 | const request = requestFromMessageData(data);
40 | if (!request) {
41 | logError("Malformed request from parent window:", [data]);
42 | return;
43 | }
44 | switch (request.kind) {
45 | case RequestKind.JOIN_AD_INTEREST_GROUP: {
46 | const { biddingLogicUrl } = request.group;
47 | // We don't check this on the library end because the library doesn't
48 | // know the allowlist, so we check it here in order to provide feedback
49 | // to developers at the earliest possible point. This check is not
50 | // security-critical because we check again at the point of use.
51 | if (
52 | !(
53 | biddingLogicUrl === undefined ||
54 | allowedLogicUrlPrefixes.some((prefix) =>
55 | biddingLogicUrl.startsWith(prefix)
56 | )
57 | )
58 | ) {
59 | logError("biddingLogicUrl is not allowlisted:", [biddingLogicUrl]);
60 | return;
61 | }
62 | // Ignore return value; any errors will have already been logged and
63 | // there's nothing more to be done about them.
64 | await storeInterestGroup(request.group);
65 | return;
66 | }
67 | case RequestKind.LEAVE_AD_INTEREST_GROUP:
68 | // Ignore return value; any errors will have already been logged and
69 | // there's nothing more to be done about them.
70 | await deleteInterestGroup(request.group.name);
71 | return;
72 | case RequestKind.RUN_AD_AUCTION: {
73 | if (ports.length !== 1) {
74 | logError(
75 | `Port transfer mismatch in request from parent window: Expected 1 port, but received ${ports.length}`
76 | );
77 | return;
78 | }
79 | const [port] = ports;
80 | const response: RunAdAuctionResponse = await runAdAuction(
81 | request.config,
82 | hostname,
83 | allowedLogicUrlPrefixes
84 | );
85 | port.postMessage(response);
86 | return;
87 | }
88 | }
89 | } finally {
90 | for (const port of ports) {
91 | port.close();
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/testing/assert_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { isArray, isObject } from "../lib/shared/guards";
9 | import {
10 | assertToBeInstanceOf,
11 | assertToBeString,
12 | assertToBeTruthy,
13 | assertToSatisfyTypeGuard,
14 | } from "./assert";
15 |
16 | describe("assertToBeTruthy", () => {
17 | it("should do nothing on a true condition", () => {
18 | assertToBeTruthy(true);
19 | expect().nothing();
20 | });
21 |
22 | it("should do nothing on a truthy condition", () => {
23 | assertToBeTruthy({});
24 | expect().nothing();
25 | });
26 |
27 | it("should throw on a false condition", () => {
28 | expect(() => {
29 | assertToBeTruthy(false);
30 | }).toThrowError(TypeError, /.*\bfalse\b.*/);
31 | });
32 |
33 | it("should throw on a falsy condition", () => {
34 | expect(() => {
35 | assertToBeTruthy(null);
36 | }).toThrowError(TypeError, /.*\bnull\b.*/);
37 | });
38 | });
39 |
40 | describe("assertToBeString", () => {
41 | it("should do nothing when the argument is a string", () => {
42 | assertToBeString("");
43 | expect().nothing();
44 | });
45 |
46 | it("should throw when the argument is not a string", () => {
47 | expect(() => {
48 | assertToBeString([]);
49 | }).toThrowError(TypeError, /.*\bArray\b.*/);
50 | });
51 |
52 | it("should include the actual null/undefined value in the error message", () => {
53 | expect(() => {
54 | assertToBeString(undefined);
55 | }).toThrowError(TypeError, /.*\bundefined\b.*/);
56 | });
57 | });
58 |
59 | describe("assertToBeInstanceOf", () => {
60 | it("should do nothing when the argument is an instance", () => {
61 | assertToBeInstanceOf([], Array);
62 | expect().nothing();
63 | });
64 |
65 | it("should throw when the argument is not an instance", () => {
66 | expect(() => {
67 | assertToBeInstanceOf([], Date);
68 | }).toThrowError(TypeError, /.*\bDate\b.*/);
69 | });
70 |
71 | it("should include the actual type in the error message", () => {
72 | expect(() => {
73 | assertToBeInstanceOf({}, Array);
74 | }).toThrowError(TypeError, /.*\bArray\b.*/);
75 | });
76 |
77 | it("should include the actual null/undefined value in the error message", () => {
78 | expect(() => {
79 | assertToBeInstanceOf(null, Array);
80 | }).toThrowError(TypeError, /.*\bnull\b.*/);
81 | });
82 | });
83 |
84 | describe("assertToSatisfyTypeGuard", () => {
85 | it("should do nothing on a true type guard check", () => {
86 | assertToSatisfyTypeGuard({}, isObject);
87 | expect().nothing();
88 | });
89 |
90 | it("should throw on a false type guard check", () => {
91 | expect(() => {
92 | assertToSatisfyTypeGuard(undefined, isObject);
93 | }).toThrowError(TypeError, /.*\bisObject\b.*/);
94 | });
95 |
96 | it("should include the actual type in the error message", () => {
97 | expect(() => {
98 | assertToSatisfyTypeGuard(new Date(), isArray);
99 | }).toThrowError(/.*\bDate\b.*/);
100 | });
101 |
102 | it("should include the actual null/undefined value in the error message", () => {
103 | expect(() => {
104 | assertToSatisfyTypeGuard(undefined, isObject);
105 | }).toThrowError(/.*\bundefined\b.*/);
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | module.exports = {
8 | env: { browser: true, es2020: true },
9 | extends: [
10 | "eslint:recommended",
11 | "plugin:jsdoc/recommended",
12 | "google",
13 | "plugin:@typescript-eslint/recommended",
14 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
15 | "prettier",
16 | ],
17 | ignorePatterns: [
18 | "dist/",
19 | // This configuration is designed for TypeScript and much of it doesn't
20 | // work properly with .js files.
21 | ".eslintrc.js",
22 | "fake_server.js",
23 | "karma.conf.js",
24 | "webpack.config.js",
25 | ],
26 | parser: "@typescript-eslint/parser",
27 | parserOptions: {
28 | sourceType: "module",
29 | tsconfigRootDir: __dirname,
30 | project: ["./tsconfig.json"],
31 | },
32 | plugins: ["@typescript-eslint", "jsdoc"],
33 | rules: {
34 | "max-len": [
35 | "warn",
36 | {
37 | // Whatever Prettier wraps lines to is acceptable.
38 | code: Infinity,
39 | // However, Prettier doesn't wrap comments.
40 | comments: 80,
41 | ignorePattern: String.raw`^\s*// eslint-disable-next-line.*`,
42 | ignoreUrls: true,
43 | },
44 | ],
45 | // Per https://eslint.org/docs/rules/no-inner-declarations, this is obsolete
46 | // since ES2015.
47 | "no-inner-declarations": "off",
48 | // Superseded by jsdoc/require-jsdoc.
49 | "require-jsdoc": "off",
50 | // Many JSDoc-related rules are superseded by TypeScript type annotation
51 | // syntax.
52 | "valid-jsdoc": "off",
53 | "jsdoc/check-indentation": "warn",
54 | "jsdoc/check-param-names": "off",
55 | "jsdoc/check-values": [
56 | "error",
57 | {
58 | allowedLicenses: true,
59 | licensePattern:
60 | "^\nCopyright 2021 Google LLC\nSPDX-License-Identifier: Apache-2\\.0$",
61 | },
62 | ],
63 | // Non-test files require both @license and a @fileoverview.
64 | "jsdoc/require-file-overview": [
65 | "error",
66 | {
67 | tags: {
68 | file: {
69 | mustExist: true,
70 | preventDuplicates: true,
71 | initialCommentsOnly: true,
72 | },
73 | license: {
74 | mustExist: true,
75 | preventDuplicates: true,
76 | initialCommentsOnly: true,
77 | },
78 | },
79 | },
80 | ],
81 | "jsdoc/require-jsdoc": ["warn", { publicOnly: true }],
82 | "jsdoc/require-param": "off",
83 | "jsdoc/require-param-type": "off",
84 | "jsdoc/require-returns": "off",
85 | "jsdoc/require-returns-type": "off",
86 | // @fileoverview tags should be allowed to have multiple paragraphs, but
87 | // there's currently no way to specify this, so we just have to disable the
88 | // rule entirely. It can be reenabled after a release goes out containing
89 | // https://github.com/gajus/eslint-plugin-jsdoc/pull/741.
90 | "jsdoc/tag-lines": "off",
91 | },
92 | overrides: [
93 | {
94 | files: ["*_test.ts"],
95 | rules: {
96 | // Same issue as with Jest
97 | // (https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/unbound-method.md),
98 | // but there's currently no Jasmine equivalent of
99 | // eslint-plugin-jest/unbound-method, so we have to just disable the
100 | // rule entirely.
101 | "@typescript-eslint/unbound-method": "off",
102 | // Test files require @license, but not @fileoverview.
103 | "jsdoc/require-file-overview": [
104 | "error",
105 | {
106 | tags: {
107 | license: {
108 | mustExist: true,
109 | preventDuplicates: true,
110 | initialCommentsOnly: true,
111 | },
112 | },
113 | },
114 | ],
115 | },
116 | },
117 | ],
118 | settings: {
119 | jsdoc: {
120 | // Google Style uses these tag names.
121 | tagNamePreference: { file: "fileoverview", returns: "return" },
122 | },
123 | },
124 | };
125 |
--------------------------------------------------------------------------------
/testing/messaging_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { assertToBeInstanceOf, assertToBeTruthy } from "./assert";
9 | import { cleanDomAfterEach } from "./dom";
10 | import {
11 | addMessagePortMatchers,
12 | iframeSendingPostMessageErrorToParent,
13 | portReceivingMessageError,
14 | postMessageFromIframeToSelf,
15 | } from "./messaging";
16 |
17 | describe("testing/messaging:", () => {
18 | beforeAll(addMessagePortMatchers);
19 |
20 | describe("toBeEntangledWith", () => {
21 | it("should match two entangled ports", () => {
22 | const { port1, port2 } = new MessageChannel();
23 | return expectAsync(port1).toBeEntangledWith(port2);
24 | });
25 |
26 | it("should not match two non-entangled ports", () => {
27 | return expectAsync(new MessageChannel().port1).not.toBeEntangledWith(
28 | new MessageChannel().port1
29 | );
30 | });
31 | });
32 |
33 | describe("postMessageFromIframeToSelf", () => {
34 | cleanDomAfterEach();
35 |
36 | it("should send a message", async () => {
37 | const iframe = document.createElement("iframe");
38 | document.body.appendChild(iframe);
39 | // Don't use awaitMessageFromIframeToSelf here because that function's own
40 | // unit tests depend on postMessageFromIframeToSelf.
41 | const messageEventPromise = new Promise>(
42 | (resolve) => {
43 | addEventListener("message", resolve, { once: true });
44 | }
45 | );
46 | const payload = crypto.getRandomValues(new Int32Array(1))[0];
47 | const { port1, port2 } = new MessageChannel();
48 | postMessageFromIframeToSelf(iframe, payload, [port1]);
49 | const { data, origin, ports, source } = await messageEventPromise;
50 | expect(data).toBe(payload);
51 | expect(origin).toBe(window.origin);
52 | expect(ports).toHaveSize(1);
53 | await expectAsync(ports[0]).toBeEntangledWith(port2);
54 | expect(source).toBe(iframe.contentWindow);
55 | });
56 |
57 | it("should clean up after itself", () => {
58 | const iframe = document.createElement("iframe");
59 | document.body.appendChild(iframe);
60 | assertToBeTruthy(iframe.contentWindow);
61 | const iframeWindowProps = Object.getOwnPropertyNames(
62 | iframe.contentWindow
63 | );
64 | postMessageFromIframeToSelf(iframe, "payload", []);
65 | expect(iframe.contentWindow.document.body.childNodes).toHaveSize(0);
66 | expect(Object.getOwnPropertyNames(iframe.contentWindow)).toEqual(
67 | iframeWindowProps
68 | );
69 | });
70 |
71 | it("should throw if iframe has no content window", () => {
72 | const iframe = document.createElement("iframe");
73 | expect(() => {
74 | postMessageFromIframeToSelf(iframe, "message payload", []);
75 | }).toThrowError();
76 | });
77 | });
78 |
79 | describe("iframeSendingPostMessageErrorToParent", () => {
80 | cleanDomAfterEach();
81 |
82 | it("should cause a deserialization failure on the current window", async () => {
83 | addEventListener("message", fail);
84 | try {
85 | await new Promise((resolve) => {
86 | addEventListener("messageerror", resolve, { once: true });
87 | document.body.appendChild(iframeSendingPostMessageErrorToParent());
88 | });
89 | } finally {
90 | removeEventListener("message", fail);
91 | }
92 | });
93 | });
94 |
95 | describe("portReceivingMessageError", () => {
96 | it("should cause a deserialization failure on the port", async () => {
97 | const port = await portReceivingMessageError();
98 | await new Promise((resolve, reject) => {
99 | port.onmessage = reject;
100 | port.onmessageerror = resolve;
101 | });
102 | });
103 |
104 | it("should clean up after itself", async () => {
105 | const existingDom = document.documentElement.cloneNode(/* deep= */ true);
106 | assertToBeInstanceOf(existingDom, HTMLHtmlElement);
107 | await portReceivingMessageError();
108 | expect(document.documentElement).toEqual(existingDom);
109 | });
110 | });
111 | });
112 |
--------------------------------------------------------------------------------
/frame/indexeddb.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utilities for performing IndexedDB operations, in order to
9 | * persistently store and retrieve data client-side.
10 | */
11 |
12 | import { logError } from "./console";
13 |
14 | const DB_NAME = "fledge-shim";
15 | const STORE_NAME = "interest-groups";
16 |
17 | const dbPromise = new Promise((resolve, reject) => {
18 | const dbRequest = indexedDB.open(DB_NAME, /* version= */ 1);
19 | dbRequest.onupgradeneeded = ({ oldVersion, newVersion }) => {
20 | // This should be called iff the database is just now being created for the
21 | // first time. It shouldn't be possible for the version numbers to differ
22 | // from the expected ones.
23 | /* istanbul ignore if */
24 | if (oldVersion !== 0 || newVersion !== 1) {
25 | throw new Error(`${oldVersion},${String(newVersion)}`);
26 | }
27 | dbRequest.result.createObjectStore(STORE_NAME);
28 | };
29 | dbRequest.onsuccess = () => {
30 | resolve(dbRequest.result);
31 | };
32 | dbRequest.onerror = () => {
33 | reject(dbRequest.error);
34 | };
35 | // Since the version number is 1 (the lowest allowed), it shouldn't be
36 | // possible for an earlier version of the same database to already be open.
37 | dbRequest.onblocked = /* istanbul ignore next */ () => {
38 | reject(new Error());
39 | };
40 | });
41 |
42 | /**
43 | * Runs an arbitrary operation on the IndexedDB object store and returns whether
44 | * it was committed successfully. `callback` has to be synchronous, but it can
45 | * create IndexedDB requests, and those requests' `onsuccess` handlers can
46 | * create further requests, and so forth; the transaction will be committed and
47 | * the promise resolved after such a task finishes with no further pending
48 | * requests. Such requests need not register `onerror` handlers, unless they
49 | * need to do fine-grained error handling; if an exception is thrown and not
50 | * caught, the transaction will be aborted without committing any writes.
51 | */
52 | export async function useStore(
53 | txMode: IDBTransactionMode,
54 | callback: (store: IDBObjectStore) => void,
55 | storeName: string = STORE_NAME
56 | ): Promise {
57 | const db = await dbPromise;
58 | return new Promise((resolve) => {
59 | let tx: IDBTransaction;
60 | try {
61 | // The FLEDGE API does not offer callers any guarantees about when writes
62 | // will be committed; for example, `joinAdInterestGroup` has a synchronous
63 | // API that triggers a background task but does not allow the caller to
64 | // await that task. Therefore, strict durability is not required for
65 | // correctness. So we'll improve latency and user battery life by opting
66 | // into relaxed durability, which allows the browser and OS to economize
67 | // on potentially expensive writes to disk.
68 | tx = db.transaction(storeName, txMode, { durability: "relaxed" });
69 | } catch (error: unknown) {
70 | /* istanbul ignore else */
71 | if (error instanceof DOMException && error.name === "NotFoundError") {
72 | logError(error.message);
73 | resolve(false);
74 | return;
75 | } else {
76 | throw error;
77 | }
78 | }
79 | tx.oncomplete = () => {
80 | resolve(true);
81 | };
82 | tx.onabort = () => {
83 | if (tx.error) {
84 | logError(tx.error.message);
85 | }
86 | resolve(false);
87 | };
88 | // No need to explicitly install an onerror handler since an error aborts
89 | // the transaction.
90 | const store = tx.objectStore(storeName);
91 | try {
92 | callback(store);
93 | } catch (error: unknown) {
94 | tx.abort();
95 | throw error;
96 | }
97 | });
98 | }
99 |
100 | declare global {
101 | interface IDBDatabase {
102 | /**
103 | * The `options` parameter is in the IndexedDB spec and is supported by
104 | * Chrome, but is absent from the default TypeScript type definitions.
105 | *
106 | * @see https://www.w3.org/TR/IndexedDB/#database-interface
107 | */
108 | transaction(
109 | storeNames: string | Iterable,
110 | mode?: IDBTransactionMode,
111 | options?: { durability?: "default" | "strict" | "relaxed" }
112 | ): IDBTransaction;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/lib/connection.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Code that receives and parses messages from the frame to the
9 | * library.
10 | */
11 |
12 | import { isObject } from "./shared/guards";
13 | import {
14 | awaitMessageFromIframeToSelf,
15 | awaitMessageToPort,
16 | } from "./shared/messaging";
17 | import { isRunAdAuctionResponse } from "./shared/protocol";
18 | import { VERSION, VERSION_KEY } from "./shared/version";
19 |
20 | /**
21 | * Waits for a handshake message from the given iframe that establishes a
22 | * messaging channel with it. Ensures that the two sides are running the same
23 | * version of the messaging protocol, to avoid surprise compatibility problems
24 | * later.
25 | *
26 | * This need be called only once after creating an iframe; subsequent
27 | * communication can occur over the returned `MessagePort`, eliminating the need
28 | * disambiguate the sources of those messages at runtime.
29 | *
30 | * @param iframe An iframe whose content will initiate the handshake by sending
31 | * a message (in production, with `connect` from `frame/main.ts`) to this
32 | * window. This iframe should not yet be attached to the document when this
33 | * function is called; this ensures that the message won't be sent until after
34 | * the listener is attached, and so won't be missed.
35 | * @param expectedOrigin `iframe`'s origin.
36 | * @return A `MessagePort` that was transferred from `iframe`'s content window
37 | * to the current window as part of the handshake message.
38 | */
39 | export async function awaitConnectionFromIframe(
40 | iframe: HTMLIFrameElement,
41 | expectedOrigin: string
42 | ): Promise {
43 | const event = await awaitMessageFromIframeToSelf(iframe);
44 | if (!event) {
45 | throw new Error(DESERIALIZATION_ERROR_MESSAGE);
46 | }
47 | const { data, ports, origin } = event;
48 | if (origin !== expectedOrigin) {
49 | throw new Error(
50 | `Origin mismatch during handshake: Expected ${expectedOrigin}, but received ${origin}`
51 | );
52 | }
53 | if (!isObject(data) || data[VERSION_KEY] !== VERSION) {
54 | const error: Partial = new Error(
55 | `Version mismatch during handshake: Expected ${JSON.stringify({
56 | [VERSION_KEY]: VERSION,
57 | })}`
58 | );
59 | error.data = data;
60 | throw error;
61 | }
62 | if (ports.length !== 1) {
63 | throw new Error(
64 | `Port transfer mismatch during handshake: Expected 1 port, but received ${ports.length}`
65 | );
66 | }
67 | return ports[0];
68 | }
69 |
70 | /**
71 | * Returns a promise that waits for the first `RunAdAuctionResponse` sent to
72 | * `port`, which is activated if it hasn't been already. The promise resolves to
73 | * the token if there is one, resolves to null if the auction had no winner, or
74 | * rejects if any kind of error or unexpected condition occurs.
75 | */
76 | export async function awaitRunAdAuctionResponseToPort(
77 | port: MessagePort
78 | ): Promise {
79 | const event = await awaitMessageToPort(port);
80 | if (!event) {
81 | throw new Error(DESERIALIZATION_ERROR_MESSAGE);
82 | }
83 | const { data, ports } = event;
84 | // Normally there shouldn't be any ports here, but in case a bogus frame sent
85 | // some, we close them to avoid memory leaks.
86 | for (const port of ports) {
87 | port.close();
88 | }
89 | if (!isRunAdAuctionResponse(data)) {
90 | const error: Partial = new Error(
91 | "Malformed response: Expected RunAdAuctionResponse"
92 | );
93 | error.data = data;
94 | throw error;
95 | }
96 | switch (data) {
97 | case true:
98 | return null;
99 | case false:
100 | throw new Error("Error occurred in frame; see console for details");
101 | default:
102 | return data;
103 | }
104 | }
105 |
106 | const DESERIALIZATION_ERROR_MESSAGE = "Message deserialization error";
107 |
108 | /**
109 | * An error with additional associated data. This exists solely to facilitate
110 | * debugging by callers of errors stemming from bad data coming from the frame.
111 | * If you encounter such an error, you probably passed a bad value for
112 | * `frameSrc`, or the party that is hosting the frame at that URL set it up
113 | * incorrectly.
114 | */
115 | export interface ErrorWithData extends Error {
116 | /** The bad data passed from the frame. */
117 | data: unknown;
118 | }
119 |
--------------------------------------------------------------------------------
/docs/code-of-conduct.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of
9 | experience, education, socio-economic status, nationality, personal appearance,
10 | race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | - Trolling, insulting/derogatory comments, and personal or political attacks
28 | - Public or private harassment
29 | - Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | - Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or reject
41 | comments, commits, code, wiki edits, issues, and other contributions that are
42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any
43 | contributor for other behaviors that they deem inappropriate, threatening,
44 | offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | This Code of Conduct also applies outside the project spaces when the Project
56 | Steward has a reasonable belief that an individual's behavior may have a
57 | negative impact on the project or its community.
58 |
59 | ## Conflict Resolution
60 |
61 | We do not believe that all conflict is bad; healthy debate and disagreement
62 | often yield positive results. However, it is never okay to be disrespectful or
63 | to engage in behavior that violates the project’s code of conduct.
64 |
65 | If you see someone violating the code of conduct, you are encouraged to address
66 | the behavior directly with those involved. Many issues can be resolved quickly
67 | and easily, and this gives people more control over the outcome of their
68 | dispute. If you are unable to resolve the matter for any reason, or if the
69 | behavior is threatening or harassing, report it. We are dedicated to providing
70 | an environment where participants feel welcome and safe.
71 |
72 | Reports should be directed to [Jeff Kaufman](mailto:jefftk@google.com), the
73 | Project Steward for FLEDGE Shim. It is the Project Steward’s duty to receive and
74 | address reported violations of the code of conduct. They will then work with a
75 | committee consisting of representatives from the Open Source Programs Office and
76 | the Google Open Source Strategy team. If for any reason you are uncomfortable
77 | reaching out to the Project Steward, please email opensource@google.com.
78 |
79 | We will investigate every complaint, but you may not receive a direct response.
80 | We will use our discretion in determining when and how to follow up on reported
81 | incidents, which may range from not taking action to permanent expulsion from
82 | the project and project-sponsored spaces. We will notify the accused of the
83 | report and provide them an opportunity to discuss it before any action is taken.
84 | The identity of the reporter will be omitted from the details of the report
85 | supplied to the accused. In potentially harmful situations, such as ongoing
86 | harassment or threats to anyone's safety, we may take action without notice.
87 |
88 | ## Attribution
89 |
90 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
91 | available at
92 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
93 |
--------------------------------------------------------------------------------
/testing/http_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { FakeRequest, FakeServerHandler, setFakeServerHandler } from "./http";
9 |
10 | describe("setFakeServerHandler", () => {
11 | const url = "https://domain.test/path?key=value";
12 | const responseBody = "response body string";
13 |
14 | it("should respond to a fetch request with a custom handler", async () => {
15 | const method = "POST";
16 | const requestHeaders = { "name-1": "Value-1", "name-2": "Value-2" };
17 | const requestBody = Uint8Array.of(1, 2, 3);
18 | const status = 206;
19 | const statusText = "Custom Status";
20 | const responseHeaders = { "name-3": "Value-3", "name-4": "Value-4" };
21 | const fakeServerHandler = jasmine
22 | .createSpy()
23 | .and.resolveTo({
24 | status,
25 | statusText,
26 | headers: responseHeaders,
27 | body: responseBody,
28 | });
29 | setFakeServerHandler(fakeServerHandler);
30 | const response = await fetch(url, {
31 | method,
32 | headers: requestHeaders,
33 | body: requestBody,
34 | credentials: "include",
35 | });
36 | expect(response.ok).toBeTrue();
37 | expect(response.status).toBe(status);
38 | expect(response.statusText).toBe(statusText);
39 | expect(Object.fromEntries(response.headers.entries())).toEqual(
40 | responseHeaders
41 | );
42 | expect(await response.text()).toBe(responseBody);
43 | expect(fakeServerHandler).toHaveBeenCalledOnceWith(
44 | jasmine.objectContaining({
45 | url: new URL(url),
46 | method,
47 | headers:
48 | jasmine.objectContaining<{ [name: string]: string }>(requestHeaders),
49 | body: requestBody,
50 | hasCredentials: true,
51 | })
52 | );
53 | });
54 |
55 | it("should respond with a default empty response if not called", async () => {
56 | const response = await fetch(url);
57 | expect(response.ok).toBeTrue();
58 | expect(response.status).toBe(200);
59 | expect(response.statusText).toBe("");
60 | expect(Object.fromEntries(response.headers.entries())).toEqual({});
61 | expect(new Uint8Array(await response.arrayBuffer())).toEqual(
62 | Uint8Array.of()
63 | );
64 | });
65 |
66 | it("should reject on a null response", async () => {
67 | setFakeServerHandler(() => Promise.resolve(null));
68 | await expectAsync(fetch(url)).toBeRejectedWithError(TypeError);
69 | });
70 |
71 | it("should reject when attempting to read a null body", async () => {
72 | const status = 206;
73 | const statusText = "Custom Status";
74 | const responseHeaders = { "name-3": "Value-3", "name-4": "Value-4" };
75 | setFakeServerHandler(() =>
76 | Promise.resolve({
77 | status,
78 | statusText,
79 | headers: responseHeaders,
80 | body: null,
81 | })
82 | );
83 | const response = await fetch(url);
84 | expect(response.ok).toBeTrue();
85 | expect(response.status).toBe(status);
86 | expect(response.statusText).toBe(statusText);
87 | expect(Object.fromEntries(response.headers.entries())).toEqual(
88 | responseHeaders
89 | );
90 | await expectAsync(response.arrayBuffer()).toBeRejectedWithError(TypeError);
91 | });
92 |
93 | it("should lowercase header names", async () => {
94 | const headerValue = "Header value";
95 | const fakeServerHandler = jasmine
96 | .createSpy()
97 | .and.resolveTo({ headers: { "a-ReSpOnSe-HeAdEr": headerValue } });
98 | setFakeServerHandler(fakeServerHandler);
99 | const response = await fetch(url, {
100 | headers: { "a-ReQuEsT-hEaDeR": headerValue },
101 | });
102 | expect(Object.fromEntries(response.headers.entries())).toEqual({
103 | "a-response-header": headerValue,
104 | });
105 | expect(fakeServerHandler).toHaveBeenCalledOnceWith(
106 | jasmine.objectContaining({
107 | headers: jasmine.objectContaining<{ [name: string]: string }>({
108 | "a-request-header": headerValue,
109 | }),
110 | })
111 | );
112 | });
113 |
114 | for (const nth of ["first", "second"]) {
115 | it(`should respond with an empty body before handler is set (${nth} time)`, async () => {
116 | expect(await (await fetch(url)).text()).toEqual("");
117 | setFakeServerHandler(() => Promise.resolve({ body: responseBody }));
118 | expect(await (await fetch(url)).text()).toEqual(responseBody);
119 | });
120 | }
121 |
122 | describe("with beforeEach", () => {
123 | beforeEach(() => {
124 | setFakeServerHandler(() => Promise.resolve({ body: responseBody }));
125 | });
126 | it("should have set a handler", async () => {
127 | expect(await (await fetch(url)).text()).toEqual(responseBody);
128 | });
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/lib/shared/messaging_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { assertToBeTruthy } from "../../testing/assert";
9 | import { cleanDomAfterEach } from "../../testing/dom";
10 | import {
11 | addMessagePortMatchers,
12 | iframeSendingPostMessageErrorToParent,
13 | portReceivingMessageError,
14 | postMessageFromIframeToSelf,
15 | } from "../../testing/messaging";
16 | import {
17 | awaitMessageFromIframeToSelf,
18 | awaitMessageFromSelfToSelf,
19 | awaitMessageToPort,
20 | } from "./messaging";
21 |
22 | describe("messaging:", () => {
23 | beforeAll(addMessagePortMatchers);
24 |
25 | describe("awaitMessageFromIframeToSelf", () => {
26 | cleanDomAfterEach();
27 |
28 | it("should receive a message", async () => {
29 | const iframe = document.createElement("iframe");
30 | document.body.appendChild(iframe);
31 | const messageEventPromise = awaitMessageFromIframeToSelf(iframe);
32 | const payload = crypto.getRandomValues(new Int32Array(1))[0];
33 | const { port1, port2 } = new MessageChannel();
34 | postMessageFromIframeToSelf(iframe, payload, [port1]);
35 | const messageEvent = await messageEventPromise;
36 | assertToBeTruthy(messageEvent);
37 | const { data, origin, ports, source } = messageEvent;
38 | expect(data).toBe(payload);
39 | expect(origin).toBe(window.origin);
40 | expect(ports).toHaveSize(1);
41 | await expectAsync(ports[0]).toBeEntangledWith(port2);
42 | expect(source).toBe(iframe.contentWindow);
43 | });
44 |
45 | it("should ignore messages from other windows", async () => {
46 | const iframe = document.createElement("iframe");
47 | document.body.appendChild(iframe);
48 | const otherIframe = document.createElement("iframe");
49 | document.body.appendChild(otherIframe);
50 | const messageEventPromise = awaitMessageFromIframeToSelf(iframe);
51 | postMessageFromIframeToSelf(otherIframe, "wrong payload", []);
52 | await expectAsync(messageEventPromise).toBePending();
53 | postMessageFromIframeToSelf(iframe, "right payload", []);
54 | await messageEventPromise;
55 | });
56 |
57 | it("should return null on message error", async () => {
58 | const iframe = iframeSendingPostMessageErrorToParent();
59 | const messageEventPromise = awaitMessageFromIframeToSelf(iframe);
60 | document.body.appendChild(iframe);
61 | expect(await messageEventPromise).toBeNull();
62 | });
63 |
64 | it("should ignore message errors from other windows", async () => {
65 | const iframe = document.createElement("iframe");
66 | document.body.appendChild(iframe);
67 | const messageEventPromise = awaitMessageFromIframeToSelf(iframe);
68 | document.body.appendChild(iframeSendingPostMessageErrorToParent());
69 | await expectAsync(messageEventPromise).toBePending();
70 | postMessageFromIframeToSelf(iframe, "payload", []);
71 | await messageEventPromise;
72 | });
73 | });
74 |
75 | describe("awaitMessageFromSelfToSelf", () => {
76 | cleanDomAfterEach();
77 |
78 | it("should receive a message", async () => {
79 | const messageEventPromise = awaitMessageFromSelfToSelf();
80 | const payload = crypto.getRandomValues(new Int32Array(1))[0];
81 | const { port1, port2 } = new MessageChannel();
82 | postMessage(payload, window.origin, [port1]);
83 | const messageEvent = await messageEventPromise;
84 | assertToBeTruthy(messageEvent);
85 | const { data, origin, ports, source } = messageEvent;
86 | expect(data).toBe(payload);
87 | expect(origin).toBe(window.origin);
88 | expect(ports).toHaveSize(1);
89 | await expectAsync(ports[0]).toBeEntangledWith(port2);
90 | expect(source).toBe(window);
91 | });
92 |
93 | it("should ignore messages from other windows", async () => {
94 | const iframe = document.createElement("iframe");
95 | document.body.appendChild(iframe);
96 | const messageEventPromise = awaitMessageFromSelfToSelf();
97 | postMessageFromIframeToSelf(iframe, "wrong payload", []);
98 | await expectAsync(messageEventPromise).toBePending();
99 | postMessage("right payload", origin, []);
100 | await messageEventPromise;
101 | });
102 | });
103 |
104 | describe("awaitMessageToPort", () => {
105 | it("should receive a message", async () => {
106 | const { port1: receiver, port2: sender } = new MessageChannel();
107 | const messageEventPromise = awaitMessageToPort(receiver);
108 | const payload = crypto.getRandomValues(new Int32Array(1))[0];
109 | const { port1, port2 } = new MessageChannel();
110 | sender.postMessage(payload, [port1]);
111 | const messageEvent = await messageEventPromise;
112 | assertToBeTruthy(messageEvent);
113 | const { data, ports } = messageEvent;
114 | expect(data).toBe(payload);
115 | expect(ports).toHaveSize(1);
116 | await expectAsync(ports[0]).toBeEntangledWith(port2);
117 | });
118 |
119 | it("should return null on message error", async () => {
120 | expect(
121 | await awaitMessageToPort(await portReceivingMessageError())
122 | ).toBeNull();
123 | });
124 |
125 | it("should close the port", async () => {
126 | const { port1: receiver, port2: sender } = new MessageChannel();
127 | const messageEventPromise = awaitMessageToPort(receiver);
128 | sender.postMessage(null);
129 | await messageEventPromise;
130 | await expectAsync(receiver).not.toBeEntangledWith(sender);
131 | });
132 | });
133 | });
134 |
--------------------------------------------------------------------------------
/lib/connection_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import { assertToBeInstanceOf } from "../testing/assert";
9 | import { cleanDomAfterEach } from "../testing/dom";
10 | import {
11 | addMessagePortMatchers,
12 | iframeSendingPostMessageErrorToParent,
13 | portReceivingMessageError,
14 | postMessageFromIframeToSelf,
15 | } from "../testing/messaging";
16 | import {
17 | awaitConnectionFromIframe,
18 | awaitRunAdAuctionResponseToPort,
19 | ErrorWithData,
20 | } from "./connection";
21 | import { RunAdAuctionResponse } from "./shared/protocol";
22 | import { VERSION, VERSION_KEY } from "./shared/version";
23 |
24 | describe("awaitConnectionFromIframe", () => {
25 | cleanDomAfterEach();
26 | beforeAll(addMessagePortMatchers);
27 |
28 | it("should perform the handshake and transfer a connected port", async () => {
29 | const iframe = document.createElement("iframe");
30 | document.body.appendChild(iframe);
31 | const portPromise = awaitConnectionFromIframe(iframe, origin);
32 | const { port1, port2 } = new MessageChannel();
33 | postMessageFromIframeToSelf(iframe, { [VERSION_KEY]: VERSION }, [port1]);
34 | await expectAsync(await portPromise).toBeEntangledWith(port2);
35 | });
36 |
37 | it("should reject on origin mismatch", async () => {
38 | const iframe = document.createElement("iframe");
39 | document.body.appendChild(iframe);
40 | const portPromise = awaitConnectionFromIframe(
41 | iframe,
42 | "https://wrong-origin.example"
43 | );
44 | postMessageFromIframeToSelf(iframe, { [VERSION_KEY]: VERSION }, [
45 | new MessageChannel().port1,
46 | ]);
47 | const error = await portPromise.then(fail, (error: unknown) => error);
48 | assertToBeInstanceOf(error, Error);
49 | expect(error.message).toContain("https://wrong-origin.example");
50 | expect(error.message).toContain(origin);
51 | });
52 |
53 | it("should reject on nullish handshake message", async () => {
54 | const iframe = document.createElement("iframe");
55 | document.body.appendChild(iframe);
56 | const portPromise = awaitConnectionFromIframe(iframe, origin);
57 | postMessageFromIframeToSelf(iframe, null, [new MessageChannel().port1]);
58 | const error = await portPromise.then(fail, (error: unknown) => error);
59 | assertToBeInstanceOf(error, Error);
60 | const errorWithData: Partial = error;
61 | expect(errorWithData.data).toBeNull();
62 | });
63 |
64 | it("should reject on wrong version", async () => {
65 | const iframe = document.createElement("iframe");
66 | document.body.appendChild(iframe);
67 | const portPromise = awaitConnectionFromIframe(iframe, origin);
68 | postMessageFromIframeToSelf(iframe, { [VERSION_KEY]: "wrong version" }, [
69 | new MessageChannel().port1,
70 | ]);
71 | const error = await portPromise.then(fail, (error: unknown) => error);
72 | assertToBeInstanceOf(error, Error);
73 | const errorWithData: Partial = error;
74 | expect(errorWithData.data).toEqual({ [VERSION_KEY]: "wrong version" });
75 | });
76 |
77 | it("should reject on wrong number of ports", async () => {
78 | const iframe = document.createElement("iframe");
79 | document.body.appendChild(iframe);
80 | const portPromise = awaitConnectionFromIframe(iframe, origin);
81 | postMessageFromIframeToSelf(iframe, { [VERSION_KEY]: VERSION }, []);
82 | await expectAsync(portPromise).toBeRejectedWithError(/.*\b0\b.*/);
83 | });
84 |
85 | it("should reject on message error", async () => {
86 | const iframe = iframeSendingPostMessageErrorToParent();
87 | document.body.appendChild(iframe);
88 | await expectAsync(
89 | awaitConnectionFromIframe(iframe, "null")
90 | ).toBeRejectedWithError();
91 | });
92 | });
93 |
94 | describe("awaitRunAdAuctionResponseToPort", () => {
95 | it("should receive a token", async () => {
96 | const { port1: receiver, port2: sender } = new MessageChannel();
97 | const tokenPromise = awaitRunAdAuctionResponseToPort(receiver);
98 | const token: RunAdAuctionResponse = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
99 | sender.postMessage(token);
100 | expect(await tokenPromise).toBe(token);
101 | });
102 |
103 | it("should receive a no-winner response", async () => {
104 | const { port1: receiver, port2: sender } = new MessageChannel();
105 | const tokenPromise = awaitRunAdAuctionResponseToPort(receiver);
106 | const response: RunAdAuctionResponse = true;
107 | sender.postMessage(response);
108 | expect(await tokenPromise).toBeNull();
109 | });
110 |
111 | it("should reject on error response", async () => {
112 | const { port1: receiver, port2: sender } = new MessageChannel();
113 | const tokenPromise = awaitRunAdAuctionResponseToPort(receiver);
114 | const response: RunAdAuctionResponse = false;
115 | sender.postMessage(response);
116 | await expectAsync(tokenPromise).toBeRejectedWithError();
117 | });
118 |
119 | it("should reject on malformed response", async () => {
120 | const { port1: receiver, port2: sender } = new MessageChannel();
121 | const tokenPromise = awaitRunAdAuctionResponseToPort(receiver);
122 | const payload = new Date();
123 | sender.postMessage(payload, [new MessageChannel().port1]);
124 | const error = await tokenPromise.then(fail, (error: unknown) => error);
125 | assertToBeInstanceOf(error, Error);
126 | const errorWithData: Partial = error;
127 | expect(errorWithData.data).toEqual(payload);
128 | });
129 |
130 | it("should reject on message error", async () => {
131 | await expectAsync(
132 | awaitRunAdAuctionResponseToPort(await portReceivingMessageError())
133 | ).toBeRejectedWithError();
134 | });
135 | });
136 |
--------------------------------------------------------------------------------
/frame/indexeddb_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import { assertToBeInstanceOf } from "../testing/assert";
8 | import { restoreErrorHandlerAfterEach } from "../testing/error";
9 | import { clearStorageBeforeAndAfter } from "../testing/storage";
10 | import { useStore } from "./indexeddb";
11 |
12 | describe("useStore", () => {
13 | clearStorageBeforeAndAfter();
14 | restoreErrorHandlerAfterEach();
15 |
16 | const value = "IndexedDB value";
17 | const key = "IndexedDB key";
18 |
19 | it("should read its own writes across multiple transactions", async () => {
20 | expect(
21 | await useStore("readwrite", (store) => {
22 | store.put(value, key);
23 | })
24 | ).toBeTrue();
25 | expect(
26 | await useStore("readonly", (store) => {
27 | const retrievalRequest = store.get(key);
28 | retrievalRequest.onsuccess = () => {
29 | expect(retrievalRequest.result).toBe(value);
30 | };
31 | })
32 | ).toBeTrue();
33 | });
34 |
35 | it("should return false and not log if the transaction is manually aborted", async () => {
36 | const consoleSpy = spyOnAllFunctions(console);
37 | expect(
38 | await useStore("readonly", (store) => {
39 | store.transaction.abort();
40 | })
41 | ).toBeFalse();
42 | expect(consoleSpy.error).not.toHaveBeenCalled();
43 | });
44 |
45 | it("should return false and log an error if opening the object store fails", async () => {
46 | const consoleSpy = spyOnAllFunctions(console);
47 | expect(
48 | await useStore("readonly", fail, "bogus-nonexistent-store-name")
49 | ).toBeFalse();
50 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(jasmine.any(String));
51 | });
52 |
53 | it("should and not commit the transaction if the main callback throws", async () => {
54 | const consoleSpy = spyOnAllFunctions(console);
55 | const errorMessage = "oops";
56 | await expectAsync(
57 | useStore("readwrite", (store) => {
58 | store.add(value, key);
59 | throw new Error(errorMessage);
60 | })
61 | ).toBeRejectedWithError(errorMessage);
62 | expect(
63 | await useStore("readonly", (store) => {
64 | const countRequest = store.count();
65 | countRequest.onsuccess = () => {
66 | expect(countRequest.result).toBe(0);
67 | };
68 | })
69 | ).toBeTrue();
70 | expect(consoleSpy.error).not.toHaveBeenCalled();
71 | });
72 |
73 | it("should not commit the transaction if a request callback throws", async () => {
74 | const errorHandler = (onerror =
75 | jasmine.createSpy("onerror"));
76 | const consoleSpy = spyOnAllFunctions(console);
77 | const error = new Error("oops");
78 | expect(
79 | await useStore("readwrite", (store) => {
80 | store.add(value, key).onsuccess = () => {
81 | throw error;
82 | };
83 | })
84 | ).toBeFalse();
85 | expect(errorHandler).toHaveBeenCalledOnceWith(
86 | "Uncaught Error: oops",
87 | jasmine.any(String),
88 | jasmine.any(Number),
89 | jasmine.any(Number),
90 | error
91 | );
92 | await useStore("readonly", (store) => {
93 | const retrievalRequest = store.count();
94 | retrievalRequest.onsuccess = () => {
95 | expect(retrievalRequest.result).toBe(0);
96 | };
97 | });
98 | // This isn't particularly desired, but we can't disable it without risking
99 | // not logging illegal operations.
100 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(jasmine.any(String));
101 | });
102 |
103 | const otherValue = "other IndexedDB value";
104 | const otherKey = "other IndexedDB key";
105 |
106 | it("should not commit the transaction if an illegal operation is attempted", async () => {
107 | const consoleSpy = spyOnAllFunctions(console);
108 | expect(
109 | await useStore("readwrite", (store) => {
110 | store.put(value, key);
111 | })
112 | ).toBeTrue();
113 | expect(
114 | await useStore("readwrite", (store) => {
115 | store.add(otherValue, otherKey);
116 | // add requires that the given key not already exist.
117 | store.add(otherValue, key);
118 | })
119 | ).toBeFalse();
120 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(jasmine.any(String));
121 | expect(
122 | await useStore("readonly", (store) => {
123 | const retrievalRequest = store.get(otherKey);
124 | retrievalRequest.onsuccess = () => {
125 | expect(retrievalRequest.result).toBeUndefined();
126 | };
127 | })
128 | ).toBeTrue();
129 | });
130 |
131 | it("should commit the transaction if an error is recovered from", async () => {
132 | const consoleSpy = spyOnAllFunctions(console);
133 | expect(
134 | await useStore("readwrite", (store) => {
135 | store.put(value, key);
136 | })
137 | ).toBeTrue();
138 | expect(
139 | await useStore("readwrite", (store) => {
140 | store.add(otherValue, otherKey);
141 | // add requires that the given key not already exist.
142 | const badRequest = store.add(otherValue, key);
143 | badRequest.onsuccess = fail;
144 | badRequest.onerror = (event) => {
145 | assertToBeInstanceOf(badRequest.error, DOMException);
146 | expect(badRequest.error.name).toBe("ConstraintError");
147 | event.preventDefault();
148 | };
149 | })
150 | ).toBeTrue();
151 | expect(
152 | await useStore("readonly", (store) => {
153 | const retrievalRequest = store.get(otherKey);
154 | retrievalRequest.onsuccess = () => {
155 | expect(retrievalRequest.result).toBe(otherValue);
156 | };
157 | })
158 | ).toBeTrue();
159 | expect(consoleSpy.error).not.toHaveBeenCalled();
160 | });
161 | });
162 |
--------------------------------------------------------------------------------
/testing/messaging.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * testing of `postMessage`-related code.
10 | */
11 |
12 | import "jasmine";
13 | import {
14 | awaitMessageFromIframeToSelf,
15 | awaitMessageToPort,
16 | } from "../lib/shared/messaging";
17 | import { assertToBeTruthy } from "./assert";
18 |
19 | declare global {
20 | // eslint-disable-next-line @typescript-eslint/no-namespace
21 | namespace jasmine {
22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
23 | interface AsyncMatchers {
24 | /**
25 | * Expect the actual value to be a `MessagePort` entangled with the
26 | * expected value, i.e., they came from the same `MessageChannel`
27 | * (possibly indirectly via `postMessage` transfers).
28 | *
29 | * This is checked by sending a message into `expected` and receiving it
30 | * out of `actual`.
31 | */
32 | toBeEntangledWith(
33 | this: jasmine.AsyncMatchers,
34 | expected: MessagePort
35 | ): Promise;
36 | }
37 | }
38 | }
39 |
40 | /**
41 | * Must be passed to `beforeAll` in each suite that uses `toBeEntangledWith`.
42 | */
43 | export function addMessagePortMatchers(): void {
44 | jasmine.addAsyncMatchers({
45 | toBeEntangledWith: () => ({
46 | async compare(actual: MessagePort, expected: MessagePort) {
47 | const messageEventPromise = awaitMessageToPort(actual);
48 | const payload = crypto.getRandomValues(new Int32Array(1))[0];
49 | expected.postMessage(payload);
50 | return {
51 | pass: await Promise.race([
52 | messageEventPromise.then(
53 | (event) => event !== null && event.data === payload
54 | ),
55 | new Promise((resolve) => {
56 | setTimeout(() => {
57 | resolve(false);
58 | }, 0);
59 | }),
60 | ]),
61 | };
62 | },
63 | }),
64 | });
65 | }
66 |
67 | /**
68 | * Sends a message via `postMessage` from `iframe`'s content window to the
69 | * current window. The `MessageEvent` is dispatched to the current window, and
70 | * its `source` property is `iframe.contentWindow`.
71 | */
72 | export function postMessageFromIframeToSelf(
73 | iframe: HTMLIFrameElement,
74 | message: unknown,
75 | transfer: Transferable[]
76 | ): void {
77 | const iframeWin = iframe.contentWindow;
78 | if (!iframeWin) {
79 | throw new Error("iframe has no content document");
80 | }
81 | const iframeDoc = iframeWin.document;
82 | const script = iframeDoc.createElement("script");
83 | script.textContent =
84 | "postMessageTo = (win, message, targetOrigin, transfer) => { win.postMessage(message, targetOrigin, transfer); }";
85 | iframeDoc.body.appendChild(script);
86 | try {
87 | script.remove();
88 | (iframeWin as unknown as WithPostMessageTo).postMessageTo(
89 | window,
90 | message,
91 | origin,
92 | transfer
93 | );
94 | } finally {
95 | delete (iframeWin as Partial).postMessageTo;
96 | }
97 | }
98 |
99 | declare interface WithPostMessageTo {
100 | postMessageTo(
101 | win: Window,
102 | message: unknown,
103 | targetOrigin: string,
104 | transfer: Transferable[]
105 | ): void;
106 | }
107 |
108 | // Deliberately triggering a messageerror from test code is surprisingly tricky.
109 | // One thing that does it is attempting to send a WebAssembly module to a
110 | // different agent cluster; see
111 | // https://html.spec.whatwg.org/multipage/origin.html#origin-keyed-agent-clusters.
112 | // Sandboxing an iframe without allow-same-origin puts it in a different agent
113 | // cluster. The inline bytes are the binary encoding of the smallest legal
114 | // WebAssembly module; see
115 | // https://webassembly.github.io/spec/core/binary/modules.html#binary-module.
116 |
117 | /**
118 | * Returns an iframe that, when attached to a document, sends a
119 | * non-deserializable message via `postMessage` to that document's window,
120 | * causing the `messageerror` event listener to fire on that window.
121 | */
122 | export function iframeSendingPostMessageErrorToParent(): HTMLIFrameElement {
123 | const iframe = document.createElement("iframe");
124 | iframe.srcdoc =
125 | "Helper";
126 | iframe.sandbox.add("allow-scripts");
127 | return iframe;
128 | }
129 |
130 | /**
131 | * Returns a `MessagePort` that, in a task immediately following the current
132 | * one, receives a `messageerror` event. The caller must attach the event
133 | * listener to the port in the same task after calling this function, or else
134 | * the event will be missed.
135 | */
136 | export async function portReceivingMessageError(): Promise {
137 | const iframe = document.createElement("iframe");
138 | iframe.srcdoc =
139 | "Helper";
140 | iframe.sandbox.add("allow-scripts");
141 | const windowMessageEventPromise = awaitMessageFromIframeToSelf(iframe);
142 | document.body.appendChild(iframe);
143 | try {
144 | const windowMessageEvent = await windowMessageEventPromise;
145 | assertToBeTruthy(windowMessageEvent);
146 | expect(windowMessageEvent.data).toBeNull();
147 | expect(windowMessageEvent.origin).toBe("null");
148 | expect(windowMessageEvent.ports).toHaveSize(1);
149 | return windowMessageEvent.ports[0];
150 | } finally {
151 | iframe.remove();
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/frame/fetch.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utilities for making HTTP requests to the network.
9 | *
10 | * These APIs generally aim to match the behavior of
11 | * https://source.chromium.org/chromium/chromium/src/+/main:content/services/auction_worklet/auction_downloader.cc
12 | * (the relevant part of Chrome's implementation of FLEDGE).
13 | */
14 |
15 | /**
16 | * The kinds of things that can happen as a result of trying to fetch JSON or
17 | * JavaScript over HTTP.
18 | */
19 | export enum FetchStatus {
20 | /**
21 | * JSON or JavaScript was successfully fetched and parsed. Note that this can
22 | * happen even if the response has a 4xx or 5xx status code; status codes are
23 | * ignored.
24 | */
25 | OK,
26 | /**
27 | * No response was received from the HTTP request. This could be due to a
28 | * network problem (no connectivity, domain doesn't exist, etc.) or the
29 | * browser not allowing the script to see the response for security reasons
30 | * (e.g., CORS problem).
31 | */
32 | NETWORK_ERROR,
33 | /**
34 | * An HTTP response was received and exposed to the script, but didn't conform
35 | * to all the preconditions (some of which aren't currently in the explainer
36 | * but are enforced by Chrome's implementation).
37 | */
38 | VALIDATION_ERROR,
39 | }
40 |
41 | /** The result of trying to fetch JSON or JavaScript over HTTP. */
42 | export type FetchResult =
43 | | {
44 | status: FetchStatus.OK;
45 | /** The response, after any applicable postprocessing. */
46 | value: T;
47 | }
48 | | { status: FetchStatus.NETWORK_ERROR }
49 | | {
50 | status: FetchStatus.VALIDATION_ERROR;
51 | /** A human-readable explanation of which precondition wasn't met. */
52 | errorMessage: string;
53 | /** Data to be logged alongside the error message. */
54 | errorData?: readonly unknown[];
55 | };
56 |
57 | async function tryFetch(
58 | url: string,
59 | mimeType: string,
60 | mimeTypeRegExp: RegExp,
61 | mimeTypeDescription: string
62 | ): Promise> {
63 | const requestInit: RequestInit = {
64 | headers: new Headers({ "Accept": mimeType }),
65 | credentials: "omit",
66 | redirect: "error",
67 | };
68 | let response;
69 | try {
70 | response = await fetch(url, requestInit);
71 | } catch (error: unknown) {
72 | /* istanbul ignore else */
73 | if (error instanceof TypeError) {
74 | return { status: FetchStatus.NETWORK_ERROR };
75 | } else {
76 | throw error;
77 | }
78 | }
79 | const contentType = response.headers.get("Content-Type");
80 | if (contentType === null) {
81 | return {
82 | status: FetchStatus.VALIDATION_ERROR,
83 | errorMessage: `Expected ${mimeTypeDescription} MIME type but received none`,
84 | };
85 | }
86 | if (!mimeTypeRegExp.test(contentType)) {
87 | return {
88 | status: FetchStatus.VALIDATION_ERROR,
89 | errorMessage: `Expected ${mimeTypeDescription} MIME type but received:`,
90 | errorData: [contentType],
91 | };
92 | }
93 | const xAllowFledge = response.headers.get("X-Allow-FLEDGE");
94 | if (xAllowFledge === null) {
95 | return {
96 | status: FetchStatus.VALIDATION_ERROR,
97 | errorMessage: "Expected header X-Allow-FLEDGE: true but received none",
98 | };
99 | }
100 | if (!/^true$/i.test(xAllowFledge)) {
101 | return {
102 | status: FetchStatus.VALIDATION_ERROR,
103 | errorMessage: "Expected header X-Allow-FLEDGE: true but received:",
104 | errorData: [xAllowFledge],
105 | };
106 | }
107 | return { status: FetchStatus.OK, value: response };
108 | }
109 |
110 | /**
111 | * Makes an HTTP request to a URL that's supposed to serve a JSON response body,
112 | * and returns the parsed response.
113 | */
114 | export async function tryFetchJson(url: string): Promise> {
115 | const result = await tryFetch(
116 | url,
117 | "application/json",
118 | // https://mimesniff.spec.whatwg.org/#json-mime-type
119 | // Chrome's behavior deviates from the spec here; it only allows +json if
120 | // the top-level type is application.
121 | /^\s*(application\/([^;]*\+)?|text\/)json\s*(;.*)?$/i,
122 | "JSON"
123 | );
124 | if (result.status !== FetchStatus.OK) {
125 | return result;
126 | }
127 | const response = result.value;
128 | let value: unknown;
129 | try {
130 | value = await response.json();
131 | } catch (error: unknown) {
132 | if (error instanceof TypeError) {
133 | return { status: FetchStatus.NETWORK_ERROR };
134 | } /* istanbul ignore else */ else if (error instanceof SyntaxError) {
135 | return {
136 | status: FetchStatus.VALIDATION_ERROR,
137 | errorMessage: error.message,
138 | };
139 | } else {
140 | throw error;
141 | }
142 | }
143 | return { status: FetchStatus.OK, value };
144 | }
145 |
146 | /**
147 | * Makes an HTTP request to a URL that's supposed to serve a JavaScript response
148 | * body, and returns the script as a string.
149 | */
150 | export async function tryFetchJavaScript(
151 | url: string
152 | ): Promise> {
153 | const result = await tryFetch(
154 | url,
155 | "application/javascript",
156 | // https://mimesniff.spec.whatwg.org/#javascript-mime-type
157 | /^\s*((application|text)\/(x-)?(jav|ecm)ascript|text\/(javascript1\.[0-5]|(j|live)script))\s*(;.*)?$/i,
158 | "JavaScript"
159 | );
160 | if (result.status !== FetchStatus.OK) {
161 | return result;
162 | }
163 | const response = result.value;
164 | let value: string;
165 | try {
166 | value = await response.text();
167 | } catch (error: unknown) {
168 | /* istanbul ignore else */
169 | if (error instanceof TypeError) {
170 | return { status: FetchStatus.NETWORK_ERROR };
171 | } else {
172 | throw error;
173 | }
174 | }
175 | return { status: FetchStatus.OK, value };
176 | }
177 |
--------------------------------------------------------------------------------
/frame/db_schema.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview CRUD operations on our data model for persistent storage in
9 | * IndexedDB, with runtime type checking.
10 | */
11 |
12 | import { AuctionAdInterestGroup } from "../lib/shared/api_types";
13 | import { isArray } from "../lib/shared/guards";
14 | import { logWarning } from "./console";
15 | import { useStore } from "./indexeddb";
16 | import { CanonicalInterestGroup } from "./types";
17 |
18 | function interestGroupFromRecord(record: unknown, key: IDBValidKey) {
19 | function handleMalformedEntry() {
20 | logWarning("Malformed entry in IndexedDB for key:", [key, ":", record]);
21 | return null;
22 | }
23 | if (!(typeof key === "string" && isArray(record) && record.length === 3)) {
24 | return handleMalformedEntry();
25 | }
26 | const [biddingLogicUrl, trustedBiddingSignalsUrl, adRecords] = record;
27 | if (
28 | !(
29 | (biddingLogicUrl === undefined || typeof biddingLogicUrl === "string") &&
30 | (trustedBiddingSignalsUrl === undefined ||
31 | typeof trustedBiddingSignalsUrl === "string") &&
32 | isArray(adRecords)
33 | )
34 | ) {
35 | return handleMalformedEntry();
36 | }
37 | const ads = [];
38 | for (const adRecord of adRecords) {
39 | if (!(isArray(adRecord) && adRecord.length === 2)) {
40 | return handleMalformedEntry();
41 | }
42 | const [renderUrl, metadata] = adRecord;
43 | if (
44 | !(
45 | typeof renderUrl === "string" &&
46 | (metadata === undefined ||
47 | (typeof metadata === "object" && metadata !== null))
48 | )
49 | ) {
50 | return handleMalformedEntry();
51 | }
52 | try {
53 | // Validate that metadata is not part of an object cycle.
54 | // This is unfortunately redundant with the serialization in the worklet;
55 | // it would be nice to figure out a way to avoid that without an
56 | // unmaintainable proliferation of types.
57 | // (We don't store the serialized string in IndexedDB for
58 | // forward-compatibility with a potential future iteration of FLEDGE that
59 | // allows arbitrary structured-cloneable metadata. See
60 | // https://bugs.chromium.org/p/chromium/issues/detail?id=1238149.)
61 | JSON.stringify(metadata);
62 | } catch {
63 | return handleMalformedEntry();
64 | }
65 | ads.push({ renderUrl, metadata });
66 | }
67 | return { name: key, biddingLogicUrl, trustedBiddingSignalsUrl, ads };
68 | }
69 |
70 | function recordFromInterestGroup(group: CanonicalInterestGroup) {
71 | return [
72 | group.biddingLogicUrl,
73 | group.trustedBiddingSignalsUrl,
74 | group.ads
75 | ? group.ads.map(({ renderUrl, metadata }) => [renderUrl, metadata])
76 | : [],
77 | ];
78 | }
79 |
80 | /**
81 | * Stores an interest group in IndexedDB and returns whether it was committed
82 | * successfully. If there's already one with the same name, each property value
83 | * of the existing interest group is overwritten if the new interest group has a
84 | * defined value for that property, but left unchanged if it does not. Note that
85 | * there is no way to delete a property of an existing interest group without
86 | * overwriting it with a defined value or deleting the whole interest group.
87 | */
88 | export function storeInterestGroup(
89 | group: AuctionAdInterestGroup
90 | ): Promise {
91 | return useStore("readwrite", (store) => {
92 | const { name } = group;
93 | const cursorRequest = store.openCursor(name);
94 | cursorRequest.onsuccess = () => {
95 | const cursor = cursorRequest.result;
96 | if (cursor) {
97 | // It shouldn't be possible for the cursor key to differ from the
98 | // expected one.
99 | /* istanbul ignore if */
100 | if (cursor.key !== name) {
101 | throw new Error(`${String(cursor.key)},${name}`);
102 | }
103 | const oldGroup = interestGroupFromRecord(cursor.value, name) ?? {
104 | biddingLogicUrl: undefined,
105 | trustedBiddingSignalsUrl: undefined,
106 | ads: [],
107 | };
108 | cursor.update(
109 | recordFromInterestGroup({
110 | name,
111 | biddingLogicUrl: group.biddingLogicUrl ?? oldGroup.biddingLogicUrl,
112 | trustedBiddingSignalsUrl:
113 | group.trustedBiddingSignalsUrl ??
114 | oldGroup.trustedBiddingSignalsUrl,
115 | ads: group.ads ?? oldGroup.ads,
116 | })
117 | );
118 | } else {
119 | store.add(
120 | recordFromInterestGroup({
121 | name,
122 | biddingLogicUrl: group.biddingLogicUrl,
123 | trustedBiddingSignalsUrl: group.trustedBiddingSignalsUrl,
124 | ads: group.ads ?? [],
125 | }),
126 | name
127 | );
128 | }
129 | };
130 | });
131 | }
132 |
133 | /**
134 | * Deletes an interest group from IndexedDB and returns whether it was committed
135 | * successfully. If there isn't one with the given name, does nothing.
136 | */
137 | export function deleteInterestGroup(name: string): Promise {
138 | return useStore("readwrite", (store) => {
139 | store.delete(name);
140 | });
141 | }
142 |
143 | /** Iteration callback type for `forEachInterestGroup`. */
144 | export type InterestGroupCallback = (group: CanonicalInterestGroup) => void;
145 |
146 | /**
147 | * Iterates over all interest groups currently stored in IndexedDB and returns
148 | * whether they were all read successfully.
149 | */
150 | export function forEachInterestGroup(
151 | callback: InterestGroupCallback
152 | ): Promise {
153 | return useStore("readonly", (store) => {
154 | const cursorRequest = store.openCursor();
155 | cursorRequest.onsuccess = () => {
156 | const cursor = cursorRequest.result;
157 | if (!cursor) {
158 | return;
159 | }
160 | const group = interestGroupFromRecord(cursor.value, cursor.key);
161 | if (group) {
162 | callback(group);
163 | }
164 | cursor.continue();
165 | };
166 | });
167 | }
168 |
--------------------------------------------------------------------------------
/lib/shared/protocol.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Serialization and deserialization functions for the messaging
9 | * protocol that's used to communicate between the library and the frame after
10 | * the initial handshake. For simple data types, type guards are used instead.
11 | */
12 |
13 | import {
14 | AuctionAd,
15 | AuctionAdConfig,
16 | AuctionAdInterestGroup,
17 | } from "./api_types";
18 | import { isArray } from "./guards";
19 |
20 | /**
21 | * A message sent from the library to the frame whenever an API call is made to
22 | * the library.
23 | */
24 | export type FledgeRequest =
25 | | { kind: RequestKind.JOIN_AD_INTEREST_GROUP; group: AuctionAdInterestGroup }
26 | | { kind: RequestKind.LEAVE_AD_INTEREST_GROUP; group: AuctionAdInterestGroup }
27 | | { kind: RequestKind.RUN_AD_AUCTION; config: AuctionAdConfig };
28 |
29 | /**
30 | * Discriminator for the above discriminated union. Each value corresponds to
31 | * a different exposed API.
32 | */
33 | export enum RequestKind {
34 | JOIN_AD_INTEREST_GROUP,
35 | LEAVE_AD_INTEREST_GROUP,
36 | RUN_AD_AUCTION,
37 | }
38 |
39 | /**
40 | * Deserializes from postMessage wire format and returns the request it
41 | * represents, or null if the input does not represent a valid request.
42 | */
43 | export function requestFromMessageData(
44 | messageData: unknown
45 | ): FledgeRequest | null {
46 | if (!isArray(messageData)) {
47 | return null;
48 | }
49 | const [kind] = messageData;
50 | switch (kind) {
51 | case RequestKind.JOIN_AD_INTEREST_GROUP: {
52 | if (messageData.length !== 5) {
53 | return null;
54 | }
55 | const [
56 | ,
57 | name,
58 | biddingLogicUrl,
59 | trustedBiddingSignalsUrl,
60 | adsMessageData,
61 | ] = messageData;
62 | if (
63 | !(
64 | typeof name === "string" &&
65 | (biddingLogicUrl === undefined ||
66 | typeof biddingLogicUrl === "string") &&
67 | (trustedBiddingSignalsUrl === undefined ||
68 | typeof trustedBiddingSignalsUrl === "string")
69 | )
70 | ) {
71 | return null;
72 | }
73 | let ads;
74 | if (isArray(adsMessageData)) {
75 | ads = [];
76 | for (const adMessageData of adsMessageData) {
77 | if (!(isArray(adMessageData) && adMessageData.length === 2)) {
78 | return null;
79 | }
80 | const [renderUrl, metadataJson] = adMessageData;
81 | if (typeof renderUrl !== "string") {
82 | return null;
83 | }
84 | const ad: AuctionAd = { renderUrl };
85 | if (typeof metadataJson === "string") {
86 | let metadata: unknown;
87 | try {
88 | metadata = JSON.parse(metadataJson);
89 | } catch {
90 | return null;
91 | }
92 | if (typeof metadata !== "object" || metadata === null) {
93 | return null;
94 | }
95 | ad.metadata = metadata;
96 | } else if (metadataJson !== undefined) {
97 | return null;
98 | }
99 | ads.push(ad);
100 | }
101 | } else if (adsMessageData !== undefined) {
102 | return null;
103 | }
104 | return {
105 | kind,
106 | group: { name, biddingLogicUrl, trustedBiddingSignalsUrl, ads },
107 | };
108 | }
109 | case RequestKind.LEAVE_AD_INTEREST_GROUP: {
110 | if (messageData.length !== 2) {
111 | return null;
112 | }
113 | const [, name] = messageData;
114 | if (typeof name !== "string") {
115 | return null;
116 | }
117 | return { kind, group: { name } };
118 | }
119 | case RequestKind.RUN_AD_AUCTION: {
120 | if (messageData.length !== 3) {
121 | return null;
122 | }
123 | const [, decisionLogicUrl, trustedScoringSignalsUrl] = messageData;
124 | if (
125 | !(
126 | typeof decisionLogicUrl === "string" &&
127 | (trustedScoringSignalsUrl === undefined ||
128 | typeof trustedScoringSignalsUrl === "string")
129 | )
130 | ) {
131 | return null;
132 | }
133 | return { kind, config: { decisionLogicUrl, trustedScoringSignalsUrl } };
134 | }
135 | default:
136 | return null;
137 | }
138 | }
139 |
140 | /** Serializes a request to postMessage wire format. */
141 | export function messageDataFromRequest(request: FledgeRequest): unknown {
142 | switch (request.kind) {
143 | case RequestKind.JOIN_AD_INTEREST_GROUP: {
144 | const {
145 | kind,
146 | group: { name, biddingLogicUrl, trustedBiddingSignalsUrl, ads },
147 | } = request;
148 | return [
149 | kind,
150 | name,
151 | biddingLogicUrl,
152 | trustedBiddingSignalsUrl,
153 | ads?.map(({ renderUrl, metadata }) => {
154 | let metadataJson;
155 | if (metadata !== undefined) {
156 | metadataJson = JSON.stringify(metadata);
157 | if (metadataJson === undefined) {
158 | throw new Error("metadata is not JSON-serializable");
159 | }
160 | }
161 | return [renderUrl, metadataJson];
162 | }),
163 | ];
164 | }
165 | case RequestKind.LEAVE_AD_INTEREST_GROUP:
166 | return [request.kind, request.group.name];
167 | case RequestKind.RUN_AD_AUCTION:
168 | return [
169 | request.kind,
170 | request.config.decisionLogicUrl,
171 | request.config.trustedScoringSignalsUrl,
172 | ];
173 | }
174 | }
175 |
176 | /**
177 | * Wire-format type of the message sent from the frame to the library in
178 | * response to a `runAdAuction` call, over the one-off `MessageChannel`
179 | * established by the library during that call. A string is a token; true means
180 | * that the auction completed successfully but did not return an ad; false means
181 | * that an error occurred.
182 | */
183 | export type RunAdAuctionResponse = string | boolean;
184 |
185 | /** Type guard for {@link RunAdAuctionResponse}. */
186 | export function isRunAdAuctionResponse(
187 | messageData: unknown
188 | ): messageData is RunAdAuctionResponse {
189 | return typeof messageData === "string" || typeof messageData === "boolean";
190 | }
191 |
--------------------------------------------------------------------------------
/testing/http.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | /**
8 | * @fileoverview Utility functions used only in test code, that facilitate
9 | * hermetic testing of HTTP requests.
10 | *
11 | * Any request to a `.test` domain will be intercepted by a service worker. This
12 | * code installs the service worker and manages its lifecycle, and allows test
13 | * code to control responses to such requests. By default, an empty response is
14 | * used.
15 | *
16 | * We use a service worker instead of simply monkeypatching `fetch` because
17 | * end-to-end tests of the library require intercepting requests made from
18 | * within the frame, and test code for those tests doesn't run within the frame,
19 | * so there's no opportunity to monkeypatch. We don't simulate everything
20 | * perfectly (e.g., CORS), but the basics of HTTP requests and responses are
21 | * covered.
22 | */
23 |
24 | import "jasmine";
25 | import { isArray } from "../lib/shared/guards";
26 | import { assertToBeTruthy } from "./assert";
27 |
28 | /**
29 | * The parts of an HTTP fetch request that this library simulates. This is
30 | * passed to callers in the `FakeServerHandler` callback type, so for caller
31 | * convenience, callers can assume they're all there and can mutate them.
32 | */
33 | export interface FakeRequest {
34 | /** Full absolute URL. */
35 | url: URL;
36 | /** GET, POST, etc. */
37 | method: string;
38 | /**
39 | * Request headers, including ones that are provided by the browser like
40 | * User-Agent, but excluding ones that the browser doesn't expose to
41 | * JavaScript code like Cookie. Names are always lowercase. Duplicate headers
42 | * aren't supported.
43 | */
44 | headers: { [name: string]: string };
45 | /**
46 | * Request body in binary format, after being read in its entirety.
47 | * Incremental streaming of the body isn't supported.
48 | */
49 | body: Uint8Array;
50 | /**
51 | * Whether credential headers (e.g., Cookie) would have been included in this
52 | * request. (They're never included in the headers property, because the
53 | * browser doesn't expose them to JavaScript.) Note that the default fetch
54 | * behavior makes this false, because the fake server only intercepts requests
55 | * to .test domains, which are assumed to be cross-origin.
56 | */
57 | hasCredentials: boolean;
58 | }
59 |
60 | /**
61 | * The parts of an HTTP fetch response that this library simulates. This is
62 | * passed from callers in the `FakeServerHandler` callback type, so for caller
63 | * convenience, callers can omit ones that aren't needed and can assume that
64 | * they won't be needed.
65 | */
66 | export interface FakeResponse {
67 | /** Numeric status code (e.g., 200, 404). */
68 | readonly status?: number;
69 | /** Status message (e.g., OK, Not Found). */
70 | readonly statusText?: string;
71 | /**
72 | * Response headers, excluding ones that the browser doesn't expose to
73 | * JavaScript code like Set-Cookie. Names will be lowercased by the browser
74 | * before being returned from fetch. A Content-Type header may be added by
75 | * the browser if one isn't provided here, but only if there is a response
76 | * body at all. Duplicate headers aren't supported.
77 | */
78 | readonly headers?: { readonly [name: string]: string };
79 | /**
80 | * Response body. Streaming isn't supported. Defaults to empty string. If null
81 | * (as opposed to undefined), causes an error to occur when attempting to read
82 | * the body.
83 | */
84 | readonly body?: string | Readonly | null;
85 | }
86 |
87 | /**
88 | * A callback that consumes an HTTP request and returns a response, or null to
89 | * simulate a network error.
90 | */
91 | export type FakeServerHandler = (
92 | request: FakeRequest
93 | ) => Promise;
94 |
95 | let registration: ServiceWorkerRegistration;
96 | let port: MessagePort;
97 | let currentHandler: FakeServerHandler;
98 |
99 | /**
100 | * For the remainder of the current test spec, whenever an HTTP request is made
101 | * to a `.test` URL, call the given handler function passing that URL, and use
102 | * its return value as the response (or fail the request, if null is returned),
103 | * instead of the default empty response. If this has already been called
104 | * earlier in the same spec, the previous handler is overwritten.
105 | *
106 | * Don't call this if an HTTP request has been sent but its response hasn't been
107 | * awaited; race conditions are likely to occur in that case.
108 | */
109 | export function setFakeServerHandler(handler: FakeServerHandler): void {
110 | currentHandler = handler;
111 | }
112 |
113 | function resetHandler() {
114 | setFakeServerHandler(() => Promise.resolve({}));
115 | }
116 | resetHandler();
117 | afterEach(resetHandler);
118 |
119 | beforeAll(async () => {
120 | const controllerChangePromise = new Promise((resolve) => {
121 | navigator.serviceWorker.addEventListener("controllerchange", resolve, {
122 | once: true,
123 | });
124 | });
125 | registration = await navigator.serviceWorker.register("/fake_server.js");
126 | await controllerChangePromise;
127 | const channel = new MessageChannel();
128 | port = channel.port1;
129 | const readyMessagePromise = new Promise>((resolve) => {
130 | port.onmessage = resolve;
131 | });
132 | assertToBeTruthy(navigator.serviceWorker.controller);
133 | navigator.serviceWorker.controller.postMessage(null, [channel.port2]);
134 | expect((await readyMessagePromise).data).toBeNull();
135 | port.onmessage = async ({ data, ports }: MessageEvent) => {
136 | assertToBeTruthy(isArray(data) && data.length === 5);
137 | const [url, method, requestHeaders, requestBody, hasCredentials] = data;
138 | assertToBeTruthy(
139 | typeof url === "string" &&
140 | typeof method === "string" &&
141 | isArray(requestHeaders) &&
142 | requestHeaders.every(
143 | (header): header is [name: string, value: string] => {
144 | /* istanbul ignore if */
145 | if (!isArray(header) || header.length !== 2) {
146 | return false;
147 | }
148 | const [name, value] = header;
149 | return typeof name === "string" && typeof value === "string";
150 | }
151 | ) &&
152 | requestBody instanceof ArrayBuffer &&
153 | typeof hasCredentials === "boolean"
154 | );
155 | const response = await currentHandler({
156 | url: new URL(url),
157 | method,
158 | headers: Object.fromEntries(requestHeaders),
159 | body: new Uint8Array(requestBody),
160 | hasCredentials,
161 | });
162 | let responseMessageData = null;
163 | if (response) {
164 | const {
165 | status,
166 | statusText,
167 | headers: responseHeaders,
168 | body: responseBody,
169 | } = response;
170 | responseMessageData = [status, statusText, responseHeaders, responseBody];
171 | }
172 | ports[0].postMessage(responseMessageData);
173 | };
174 | });
175 |
176 | afterAll(() => {
177 | port.postMessage(null);
178 | port.close();
179 | return registration.unregister();
180 | });
181 |
--------------------------------------------------------------------------------
/frame/main_test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2021 Google LLC
4 | * SPDX-License-Identifier: Apache-2.0
5 | */
6 |
7 | import "jasmine";
8 | import {
9 | awaitMessageFromSelfToSelf,
10 | awaitMessageToPort,
11 | } from "../lib/shared/messaging";
12 | import {
13 | isRunAdAuctionResponse,
14 | messageDataFromRequest,
15 | RequestKind,
16 | } from "../lib/shared/protocol";
17 | import { VERSION, VERSION_KEY } from "../lib/shared/version";
18 | import {
19 | assertToBeString,
20 | assertToBeTruthy,
21 | assertToSatisfyTypeGuard,
22 | } from "../testing/assert";
23 | import { cleanDomAfterEach } from "../testing/dom";
24 | import {
25 | FakeRequest,
26 | FakeServerHandler,
27 | setFakeServerHandler,
28 | } from "../testing/http";
29 | import { clearStorageBeforeAndAfter } from "../testing/storage";
30 | import { main } from "./main";
31 |
32 | describe("main", () => {
33 | cleanDomAfterEach();
34 | clearStorageBeforeAndAfter();
35 |
36 | const allowedLogicUrlPrefixesJoined = "https://dsp.test/,https://ssp.test/";
37 | const renderUrl = "about:blank#ad";
38 | const biddingLogicUrl = "https://dsp.test/bidder.js";
39 | const decisionLogicUrl = "https://ssp.test/scorer.js";
40 |
41 | it("should connect to parent window and handle requests from it", async () => {
42 | const fakeServerHandler =
43 | jasmine.createSpy("fakeServerHandler");
44 | fakeServerHandler
45 | .withArgs(
46 | jasmine.objectContaining({
47 | url: new URL(biddingLogicUrl),
48 | })
49 | )
50 | .and.resolveTo({
51 | headers: {
52 | "Content-Type": "application/javascript",
53 | "X-Allow-FLEDGE": "true",
54 | },
55 | body: [
56 | "function generateBid() {",
57 | " return { ad: null, bid: 0.03, render: 'about:blank#ad' };",
58 | "}",
59 | ].join("\n"),
60 | });
61 | fakeServerHandler
62 | .withArgs(
63 | jasmine.objectContaining({
64 | url: new URL(decisionLogicUrl),
65 | })
66 | )
67 | .and.resolveTo({
68 | headers: {
69 | "Content-Type": "application/javascript",
70 | "X-Allow-FLEDGE": "true",
71 | },
72 | body: "function scoreAd() { return 10; }",
73 | });
74 | setFakeServerHandler(fakeServerHandler);
75 | const iframe = document.createElement("iframe");
76 | document.body.appendChild(iframe);
77 | const handshakeMessageEventPromise = awaitMessageFromSelfToSelf();
78 | assertToBeTruthy(iframe.contentWindow);
79 | main(iframe.contentWindow, allowedLogicUrlPrefixesJoined);
80 | const handshakeMessageEvent = await handshakeMessageEventPromise;
81 | assertToBeTruthy(handshakeMessageEvent);
82 | expect(handshakeMessageEvent.data).toEqual({ [VERSION_KEY]: VERSION });
83 | expect(handshakeMessageEvent.ports).toHaveSize(1);
84 | const [port] = handshakeMessageEvent.ports;
85 | port.postMessage(
86 | messageDataFromRequest({
87 | kind: RequestKind.JOIN_AD_INTEREST_GROUP,
88 | group: {
89 | name: "interest group name",
90 | biddingLogicUrl,
91 | ads: [{ renderUrl, metadata: { "price": 0.02 } }],
92 | },
93 | })
94 | );
95 | const { port1: receiver, port2: sender } = new MessageChannel();
96 | const auctionMessageEventPromise = awaitMessageToPort(receiver);
97 | port.postMessage(
98 | messageDataFromRequest({
99 | kind: RequestKind.RUN_AD_AUCTION,
100 | config: { decisionLogicUrl },
101 | }),
102 | [sender]
103 | );
104 | const auctionMessageEvent = await auctionMessageEventPromise;
105 | assertToBeTruthy(auctionMessageEvent);
106 | const { data: auctionResponse } = auctionMessageEvent;
107 | assertToSatisfyTypeGuard(auctionResponse, isRunAdAuctionResponse);
108 | assertToBeString(auctionResponse);
109 | expect(sessionStorage.getItem(auctionResponse)).toBe(renderUrl);
110 | });
111 |
112 | const token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
113 |
114 | it("should render an ad", () => {
115 | sessionStorage.setItem(token, renderUrl);
116 | const outerIframe = document.createElement("iframe");
117 | outerIframe.src = "about:blank#" + token;
118 | document.body.appendChild(outerIframe);
119 | assertToBeTruthy(outerIframe.contentWindow);
120 | main(outerIframe.contentWindow, allowedLogicUrlPrefixesJoined);
121 | const innerIframe =
122 | outerIframe.contentWindow.document.querySelector("iframe");
123 | assertToBeTruthy(innerIframe);
124 | expect(innerIframe.src).toBe(renderUrl);
125 | });
126 |
127 | it("should render with the exact same dimensions as the outer iframe, with no borders or scrollbars", async () => {
128 | sessionStorage.setItem(token, renderUrl);
129 | const outerIframe = document.createElement("iframe");
130 | outerIframe.src = "about:blank#" + token;
131 | outerIframe.style.width = "123px";
132 | outerIframe.style.height = "45px";
133 | document.body.appendChild(outerIframe);
134 | assertToBeTruthy(outerIframe.contentWindow);
135 | main(outerIframe.contentWindow, allowedLogicUrlPrefixesJoined);
136 | const innerIframe =
137 | outerIframe.contentWindow.document.querySelector("iframe");
138 | assertToBeTruthy(innerIframe);
139 | const expectedRect = {
140 | left: 0,
141 | x: 0,
142 | top: 0,
143 | y: 0,
144 | right: 123,
145 | width: 123,
146 | bottom: 45,
147 | height: 45,
148 | };
149 | expect(innerIframe.getBoundingClientRect().toJSON()).toEqual(expectedRect);
150 | const rectsInViewport = await new Promise(
151 | (resolve) => {
152 | new IntersectionObserver(resolve).observe(innerIframe);
153 | }
154 | );
155 | expect(rectsInViewport).toHaveSize(1);
156 | expect(rectsInViewport[0].boundingClientRect.toJSON()).toEqual(
157 | expectedRect
158 | );
159 | expect(getComputedStyle(innerIframe).borderRadius).toEqual("0px");
160 | expect(getComputedStyle(innerIframe).borderStyle).toEqual("none");
161 | // There's no other way to check this as far as we know.
162 | expect(innerIframe.scrolling).toEqual("no");
163 | });
164 |
165 | it("should log an error on invalid token", () => {
166 | const consoleSpy = spyOnAllFunctions(console);
167 | const iframe = document.createElement("iframe");
168 | iframe.src = "about:blank#" + token;
169 | document.body.appendChild(iframe);
170 | const win = iframe.contentWindow;
171 | assertToBeTruthy(win);
172 | main(win, allowedLogicUrlPrefixesJoined);
173 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(
174 | jasmine.any(String),
175 | "#" + token
176 | );
177 | });
178 |
179 | it("should log an error if running on top window", () => {
180 | const consoleSpy = spyOnAllFunctions(console);
181 | main(top, allowedLogicUrlPrefixesJoined);
182 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(jasmine.any(String));
183 | });
184 |
185 | it("should log an error if allowlisted logic URL prefix is not a valid absolute URL", () => {
186 | const consoleSpy = spyOnAllFunctions(console);
187 | main(window, "/relative/");
188 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(
189 | jasmine.any(String),
190 | "/relative/"
191 | );
192 | });
193 |
194 | it("should log an error if allowlisted logic URL prefix does not end with a slash", () => {
195 | const consoleSpy = spyOnAllFunctions(console);
196 | main(window, "https://dsp.test");
197 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(
198 | jasmine.any(String),
199 | "https://dsp.test"
200 | );
201 | });
202 |
203 | it("should log an error if allowlisted logic URL prefix is insecure HTTP", () => {
204 | const consoleSpy = spyOnAllFunctions(console);
205 | main(window, "http://insecure-dsp.test/");
206 | expect(consoleSpy.error).toHaveBeenCalledOnceWith(
207 | jasmine.any(String),
208 | "http://insecure-dsp.test/"
209 | );
210 | });
211 | });
212 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FLEDGE Shim
2 |
3 | Note: this project is on hold. Chrome's prototype FLEDGE implementation is
4 | accessible locally with feature flags, and per the
5 | [Privacy Sandbox Timeline](https://privacysandbox.com/timeline/) broader testing
6 | should be possible soon.
7 |
8 | This is the beginning of a pure-JavaScript implementation of the
9 | [FLEDGE proposal](https://github.com/WICG/turtledove/blob/main/FLEDGE.md), on
10 | top of existing browser APIs. The goal is to allow testing as much of FLEDGE as
11 | possible, in as realistic a manner as possible, given the constraint of not
12 | being able to add new features to the browser itself.
13 |
14 | ## Status
15 |
16 | This project has not yet been tested in production; use at your own risk.
17 | Furthermore, most of the API is not yet implemented.
18 |
19 | ## Building
20 |
21 | As with most JavaScript projects, you'll need Node.js and npm. Install
22 | dependencies with `npm install` as per usual.
23 |
24 | In order to build the frame, you have to set a list of allowed URL prefixes for
25 | the worklets. The frame will only allow `biddingLogicUrl` and `decisionLogicUrl`
26 | values that start with those prefixes. Each such prefix must consist of an HTTPS
27 | origin optionally followed by a path, and must end with a slash. So, for
28 | instance, you could allow worklet scripts under `https://dsp.example`, or
29 | `https://ssp.example/js/`.
30 |
31 | The reason for this is because worklet scripts have access to cross-site
32 | interest group and related data, and nothing prevents them from exfiltrating
33 | that data. So, if you're going to host the frame and have such cross-site data
34 | stored in its origin in users' browsers, you should make sure to only allow
35 | worklet scripts from sources that you trust not to do that.
36 |
37 | Once you have an allowlist, set the `ALLOWED_LOGIC_URL_PREFIXES` environment
38 | variable to the allowlist with the entries separated by commas, then run
39 | `npm run build`. For example, on Mac or Linux, you might run
40 | `ALLOWED_LOGIC_URL_PREFIXES=https://dsp.example/,https://ssp.example/js/ npm run build`;
41 | on Windows PowerShell, the equivalent would be
42 | `$Env:ALLOWED_LOGIC_URL_PREFIXES = "https://dsp.example/,https://ssp.example/js/"; npm run build`.
43 |
44 | ## Design
45 |
46 | FLEDGE requires a way to store information in the browser that is (a) accessible
47 | across all websites but (b) only through JavaScript access control.
48 | `localStorage` in a cross-origin iframe fits this well. In Chrome this is not
49 | partitioned and only JavaScript running within the iframe can read or modify the
50 | data.
51 |
52 | The shim is divided into two pieces:
53 |
54 | - A _frame_ that's embedded onto the page cross-origin in an `