├── .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 `