Permissions and consent
The FKN browser extension asks the user before your app reads or acts on an embedded site. What it asks for is a category of access on one website, not a single action, so accepting Interaction on example.org covers every click, fill and hover your app makes there. This page covers the four categories, the website a grant names, the sheet and its answers, the reason you attach to a call, asking upfront, the keys behind the rows, the activity log, the card the cloud backend draws, and when to ask.
Only the extension backend shows a sheet, see backends, so pin it and catch the refusal:
try { await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#play').click: (options?: PositionOptions | undefined) => Promise<void>
click({ reason?: string | undefined
reason: 'Start playback from this app' })} catch (var error: unknown
error) { const { const name: string
name, const message: string
message } = var error: unknown
error as interface Error
Error const name: string
name // 'PermissionDeniedError', when the user refused const message: string
message // 'Permission denied: interaction on example.org (act.click #play)'}The message names the row the user answered, interaction on example.org, and then the concrete call in brackets, act.click #play. The key names the action and the scope names where: for a locator call that is the Locator you built by chaining selectors, rendered as text, see the chain.
The extension builds the error by assigning the name PermissionDeniedError to a plain Error rather than as a class, so test error.name, or the exported isPermissionDenied. Neither isLocatorDenied nor isTerminalError matches it, and its permissionKey, grantKey, category, site, scope and hosts fields do not survive the hop out of the extension, see a refusal neither guard matches.
The four categories
Section titled “The four categories”A category is the unit the extension asks for and stores. Every capability belongs to exactly one, and a stored grant is one row of your app’s host, the category, and one website. The four ids are interaction, storage, network and evaluation:
| Id | Row title | What accepting it covers |
|---|---|---|
interaction | Interact with <site> | Clicking, typing and hovering on the site, through the session in the frame |
storage | Read data from <site> | Reading the text, links and details the site shows |
network | Make requests to <site> as you | Loading pages and data from the site and sending changes back |
evaluation | Run code on <site> | Injecting unfiltered styles or code into the site |
Each category also has a heading, which is what the popup, dashboard and activity log print: Interaction, Site data, Network and Evaluation. The storage heading is Site data rather than Storage, because that word already names FKN account storage in the same product. This page names a row by its heading, and the sheet shows the title above it with the site filled in. Network and Evaluation are marked Sensitive, since every capability in them reaches past the framed page.
One row rather than one per action is the point. A player with a dozen controls would otherwise raise a dozen sheets, and a person who has answered four prompts stops reading the fifth, which makes the fifth answer worth less than the first. A single row that names what the app will do on one site is the ask someone can actually weigh.
Which website a grant covers
Section titled “Which website a grant covers”A grant names a website, not only an app, so the same app working on two sites needs two answers. The site is the hostname of the document the call lands in, read from that document and checked again where the call runs, so a page that navigates itself somewhere else does not inherit the answer.
A row also names every other host the same answer covers: the site first, then the hosts the attachment declared in domains. The sheet prints that list under the row as on www.example.org and 2 more, and Apply writes one grant row per host, so a sign-in page on sso.example.org is covered when the attach declared it. frame.fetch() consults the target host of an absolute url instead, because that is where the request goes.
The sheet
Section titled “The sheet”When a call needs a category the user has not granted, the extension draws the consent sheet over the page. The sheet is headed <app host> is asking to… and shows one row per category: the title with the site, the severity, the Sensitive badge where it applies, the description, the site line, a Needed now for line naming the concrete actions, and your reason. The user allows or denies each row and chooses how long the extension remembers the answer:
- Once holds for the rest of this page load and is never stored
- Session is stored for the rest of the browser session
- Always is stored until the user revokes it
Every category row starts at Allow and Session. The extension remembers a denial the same way it remembers a grant, so a plain Deny is stored for the session and every later call in that category on that site rejects with no sheet until the user revokes it. Only Once stops at the page load, and a dismissal does not even do that.
A dismissal holds for nothing: the extension logs it as a denial remembered once, and the next call asks again. Closing the sheet counts as a dismissal, and so does covering it for three checks in a row.
With more than one row the sheet offers Remember for all and Deny all. A row raised by locator calls carries one Hold to highlight button per element it covers, and a row from an upfront ask has none. The apply button stays disabled, reading Reading…, for the first 500 ms.
A grant is what a later call finds instead of a sheet:
await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#title').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent({ reason?: string | undefined
reason: 'Show the title in this app' }) // the sheet, one Site data rowawait const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('.byline').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent() // no sheet, the same row already covers itBoth reads belong to Site data, so the second is answered by the grant the first one stored. The extension consults the stored grants first, on every call, so a revoke from its toolbar popup or dashboard takes effect on the next call. The popup groups a user’s grants by category and lists the hosts each one covers.
Critical rows
Section titled “Critical rows”A capability at severity 4 is critical: it is never bundled into a category grant, and it asks on its own every time it runs. Its row is drawn under its category’s heading with its own Allow and Deny, defaults to Once, and offers no Always. Denying the category row on the same sheet denies its critical rows with it, and a category grant on its own never answers one.
No shipped key sits at severity 4, so you will not meet a critical row yet. The rule is described here because it is what makes a category safe to accept: the ceiling on what one Allow can cover is part of the offer.
Giving a reason
Section titled “Giving a reason”reason is a sentence for the user, shown on the row under the label App’s reason. Write it as the outcome they get rather than the operation you run:
await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#search').fill: (value: string, options?: LocatorOptions | undefined) => Promise<void>
fill('big buck bunny', { reason?: string | undefined
reason: 'Search the catalog for you' }) // an Interaction row, then the fillThe sheet shows that sentence on the Interaction row, beside the chip type. A row raised by several calls lists the first two distinct reasons it collected. Every locator action takes a reason, and so do permissions.request(), frame.request(), frame.fetch(), extension.fetch() and extension.setRequestHeaderRule(), see fetch().
extension.cookies.get() is the only call that can put a row on the sheet without one: its row shows the category’s own words and the cookie’s host, and no App’s reason line. attachFrame() and goto() take no reason either, because their severity-0 row is never shown.
Asking before acting
Section titled “Asking before acting”ensure(operation, options?) on a chain runs the consent check for operation and touches no element, so the sheet appears where you want it rather than in the middle of a click. It asks for the operation’s category on the sites the attachment covers, which is the row a real call would have raised:
try { // one sheet row: Interact with example.org await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('.controls').ensure: (operation: "click" | "fill" | "hover" | "textContent" | "getAttribute" | "isVisible" | "count" | "exists" | "videoElement", options?: EnsureOptions) => Promise<void>
ensure('click', { reason: string
reason: 'Control the player from this app' })
await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('.controls').locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#play').click: (options?: PositionOptions | undefined) => Promise<void>
click() // covered, no sheet await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#outside').click: (options?: PositionOptions | undefined) => Promise<void>
click() // covered too, the grant names the site and not the box} catch (var error: unknown
error) { const { const name: string
name, const message: string
message } = var error: unknown
error as interface Error
Error if (const name: string
name !== 'PermissionDeniedError') throw var error: unknown
error const message: string
message // 'Permission denied: interaction on example.org (act.click .controls)'}One row covers every click on example.org, inside .controls and out. The grant is keyed by the site rather than by the selector, so a chain you rewrite when the site redesigns keeps its answer. subtree: true is still accepted and now does nothing, since a category row already covers the whole document.
Several at once
Section titled “Several at once”frame.request(requests) asks for whole categories on the frame’s own hosts, before any call reaches the site. It answers one grant per request, in the order asked, and a refusal is allow: false rather than a rejection:
const const frame: Frame
frame = await function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')!, domains?: string[] | undefined
domains: ['example.org', 'sso.example.org'],})await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/catalog')
const const grants: CategoryAnswer[]
grants = await const frame: Frame
frame.function request(requests: CategoryRequest[]): Promise<CategoryGrant[]>
Asks for whole categories on this attachment's hosts, before any call. Resolves per request, in
the order they were asked; a refusal is allow: false, never a rejection. An ask naming a
category or key the registry cannot place on a row is a TypeError, thrown before it is sent.
request([ { category: PermissionCategory
category: 'storage', reason: string
reason: 'Read the page heading' }, { category: PermissionCategory
category: 'interaction', reason: string
reason: 'Type the search query and press Search for you' },])const grants: CategoryAnswer[]
grants.Array<CategoryAnswer>.map<boolean>(callbackfn: (value: CategoryAnswer, index: number, array: CategoryAnswer[]) => boolean, thisArg?: any): boolean[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
map(grant: CategoryAnswer
grant => grant: CategoryAnswer
grant.allow: boolean
allow) // [true, false] if the second row was refused
if (const grants: CategoryAnswer[]
grants.Array<CategoryAnswer>.every(predicate: (value: CategoryAnswer, index: number, array: CategoryAnswer[]) => unknown, thisArg?: any): boolean (+1 overload)
Determines whether all the members of an array satisfy the specified test.
every(grant: CategoryAnswer
grant => grant: CategoryAnswer
grant.allow: boolean
allow)) { await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#search').fill: (value: string, options?: LocatorOptions | undefined) => Promise<void>
fill('big buck bunny') // covered, no further sheet await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('button[type="submit"]').click: (options?: PositionOptions | undefined) => Promise<void>
click()}Each ask becomes one row naming the document’s host first and the declared domains after it, and Apply writes a grant for every host on that list. attachFrame({ permissions }) runs the same ask as the attach’s last step, so the user answers once before your first call, and a refusal there surfaces on the first refused operation rather than rejecting the attach. A blank frame with no domains has no host to name and throws frame.request: navigate the frame or declare domains first.
permissions.request(requests) is the same ask from the page, for the capabilities that have no frame. It takes hosts, the sites the grant applies to, and answers allow: true only where every one of them was granted:
import { const permissions: { request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>;}
permissions } from '@fkn/lib'import type { type PermissionRequest = (CategoryRequest & { hosts: string[];}) | LegacyPermissionRequest
hosts is what makes an item a category ask; without it the item is the legacy per-key one.
PermissionRequest } from '@fkn/lib'
const const requests: PermissionRequest[]
requests: type PermissionRequest = (CategoryRequest & { hosts: string[];}) | LegacyPermissionRequest
hosts is what makes an item a category ask; without it the item is the legacy per-key one.
PermissionRequest[] = [ { category: PermissionCategory
category: 'network', hosts: string[]
hosts: ['api.example.org'], reason: string
reason: 'Load your watch history' },]const [const grant: PermissionGrant
grant] = await const permissions: { request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>;}
permissions.request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>
Asks for permissions on one sheet (extension) or card (cloud), in the order given, and answers
one grant per request.
A category ask needs hosts, the sites the grant applies to, and is answered allow: true only
where every one of them was granted. It needs an extension that serves categories and throws
ExtensionOutdatedError below that; a legacy { key, scope } item is forwarded to any version.
An ask naming a category or key the registry cannot place on a row is a TypeError, thrown
before anything is sent: its answer would have to name a category the caller never asked for.
request(const requests: PermissionRequest[]
requests) // one sheet, one Network rowconst grant: PermissionGrant
grant.allow: boolean
allow // true once the user allowed itA category ask comes back as { category, hosts, allow }, with key set only where the ask named a critical key’s own row. An ask whose hosts normalise to nothing throws permissions.request: pass hosts, the sites the grant applies to before anything is sent, since a row with no host is one nobody could revoke.
frame.request() and the category form of permissions.request() need an extension that serves categories, ABI 2, and reject with ExtensionOutdatedError below it, so read readExtensionHandshake() first and keep a branch for the older build. attachFrame({ permissions }) reads the same handshake itself and skips the ask with a console warning rather than failing the attach, leaving each capability to be asked for as it is first used. On the cloud backend the broker’s card serves frame.request() and attachFrame({ permissions }), and there is no handshake to read. permissions.request() is an extension call on every backend.
The pre-category ask, { key, scope?, reason? } with no hosts, is forwarded to any extension version and is what that older branch sends. A current extension maps it onto the same row a category ask would draw, taking the site from the scope, so a key scoped to a host or an origin works and a locator key scoped to a selector answers allow: false and stores nothing, because a selector names no site. The one exception is '*' or no scope at all, which names the whole document: that is asked on your own page’s host, and a later call into a third-party frame asks again for the frame’s host.
Without the extension the call runs the missing-extension handler after the exposure wait and then rejects with The FKN WebExtension is not installed, enabled or not exposed on this page., see when the extension is missing.
Permission keys
Section titled “Permission keys”Twenty keys exist. Each belongs to one category, and the table says what raises it and which row it lands on:
| Key | Severity | Category | Raised by |
|---|---|---|---|
read.text | 1 | Site data | textContent() |
read.info | 1 | Site data | getAttribute() |
read.visible | 0 | Site data | isVisible() |
read.check | 0 | Site data | exists() |
read.count | 0 | Site data | count() |
act.click | 3 | Interaction | click() |
act.type | 1 | Interaction | fill() |
act.hover | 1 | Interaction | hover() |
media.video | 0 | Interaction | videoElement() |
media.appear | 0 | Interaction | addStyleTag() |
embed.iframe | 0 | Interaction | attachFrame() |
embed.open | 0 | Interaction | goto() |
media.appearU | 2 | Evaluation | addStyleTag({ noSanitize: true }) |
frame.fetchRead | 3 | Network | frame.fetch() with GET, HEAD, OPTIONS |
frame.fetchWrite | 3 | Network | frame.fetch() with another method |
network.fetch | 0 | Network | extension.fetch() without credentials |
network.fetchCredentialed | 3 | Network | extension.fetch() with credentials: 'include' |
network.fetchLocal | 3 | Network | extension.fetch() to a local network address |
network.readCookie | 3 | Network | extension.cookies.get() |
network.modifyRequestHeaders | 2 | Network | extension.setRequestHeaderRule() |
Severity runs 0 None, 1 Low, 2 Medium, 3 High, with a 4 declared that no key uses, see severity. A key at severity 0 is granted silently and never stored, and the extension logs one auto row per origin, key and scope per document in the activity log, the on-device record of what an app did. A key from 1 to 3 is bundled into its category, and a row shows its category’s severity, the highest among all its bundled members, whatever it is being asked for right now (an Interaction row reads High even for a lone fill()).
A category row lists the concrete actions it is needed for right now under Needed now for, one chip per key: click, type, hover, read text, read details, load, send, load as you, local network, read cookie, rewrite headers and unfiltered styles. The root fetch() raises network.fetchLocal for a local address, then network.fetchCredentialed or network.fetch, whenever the extension is exposed, see how the root fetch decides.
@fkn/lib exports seven descriptors, one per key raised outside a locator chain: attachFramePermission, gotoPermission, fetchPermission, fetchCredentialedPermission, fetchLocalPermission, readCookiePermission and modifyRequestHeadersPermission. There is no exported descriptor for the thirteen locator keys, so do not look for a clickPermission. CATEGORIES carries the row copy itself, so your app can say the same words before it asks:
import { const CATEGORIES: readonly CategoryMeta[]
The four categories, in display order. The word Storage never appears: it already means FKN account storage.
CATEGORIES, const permissions: { request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>;}
permissions } from '@fkn/lib'
const const network: CategoryMeta
network = const CATEGORIES: readonly CategoryMeta[]
The four categories, in display order. The word Storage never appears: it already means FKN account storage.
CATEGORIES.ReadonlyArray<CategoryMeta>.find(predicate: (value: CategoryMeta, index: number, obj: readonly CategoryMeta[]) => unknown, thisArg?: any): CategoryMeta | undefined (+1 overload)
Returns the value of the first element in the array where predicate is true, and undefined
otherwise.
find(category: CategoryMeta
category => category: CategoryMeta
category.id: PermissionCategory
id === 'network')!var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<Element>(selectors: string): Element | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('#notice')!.Element.textContent: string | null
textContent = const network: CategoryMeta
network.description: (site: string, signedIn: boolean) => string
signedIn is false when the frame was attached with syncCookies: false
description('api.example.org', true) // the row's own words, before the sheet
const [const grant: PermissionGrant
grant] = await const permissions: { request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>;}
permissions.request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>
Asks for permissions on one sheet (extension) or card (cloud), in the order given, and answers
one grant per request.
A category ask needs hosts, the sites the grant applies to, and is answered allow: true only
where every one of them was granted. It needs an extension that serves categories and throws
ExtensionOutdatedError below that; a legacy { key, scope } item is forwarded to any version.
An ask naming a category or key the registry cannot place on a row is a TypeError, thrown
before anything is sent: its answer would have to name a category the caller never asked for.
request([{ category: PermissionCategory
category: 'network', hosts: string[]
hosts: ['api.example.org'], reason: string
reason: 'Load your watch history' }])const grant: PermissionGrant
grant.allow: boolean
allow // true once the user allowed itThe notice carries the description the row is about to show, with the site filled in and the signed-in sentence chosen by the attachment’s syncCookies. A descriptor’s scope is still accepted as the key of an ask, which asks for that key’s own category on the hosts named. The exact shape of permissions and its types lives under @fkn/lib/extension.
The activity log
Section titled “The activity log”The extension writes a row to its toolbar popup and dashboard when the sheet is answered or dismissed, when it grants a severity-0 call silently and when a stored grant covers a call. A row carries the origin, the site the call landed on, the concrete key, the scope, the outcome (allowed, denied, auto or covered) and the answer’s remember mode, once, session or always. The log stays per concrete key rather than per category, so a covered click reads Click on the page with Covered · Interaction beside it.
An auto row is written once per origin, key and scope per document, a covered row once per origin, key, scope and site. frame.fetch() adds one receipt row per call with the method, the path and the status, whatever answered the permission:
const const result: FrameFetchResult
result = await const frame: extension.Frame
frame.fetch: (url: string, init?: FrameFetchOptions | undefined) => Promise<FrameFetchResult>
fetch('https://example.org/api/catalog.json', { reason?: string | undefined
reason: 'Load your catalog' }) // a Network row, then one receipt rowconst result: FrameFetchResult
result.status: number
status // whatever example.org answered, which the receipt recordsThe receipt lands before the result reaches you. A call whose receipt cannot be written has its result withheld, with frame.fetch: the call completed but its audit receipt could not be recorded; result withheld. A failing call writes one receipt per identical call per 60,000 ms rather than one per retry.
The extension deletes rows older than 30 days on every write, and a read returns the newest 500. Nothing in @fkn/lib reads the log. It is the user’s record of what your app was allowed to do, so point them at the extension’s popup when they ask what was allowed.
Consent on the cloud backend
Section titled “Consent on the cloud backend”The cloud backend, the render proxy, has no extension. Nothing there draws a sheet, stores an answer for longer than the tab, or writes an activity log. What it has is the same four categories and the broker’s card, drawn by the connection your app holds into FKN.
@fkn/lib asks the card before it sends a gated operation. The card names your app’s host, the hosts the answer covers and each category’s title and description, stacks one row per category asked, and offers Allow for this session or Not now. Your reason is never shown there, and the host list is cut to 16 hosts.
try { const [const interaction: CategoryAnswer
interaction] = await const frame: Frame
frame.function request(requests: CategoryRequest[]): Promise<CategoryGrant[]>
Asks for whole categories on this attachment's hosts, before any call. Resolves per request, in
the order they were asked; a refusal is allow: false, never a rejection. An ask naming a
category or key the registry cannot place on a row is a TypeError, thrown before it is sent.
request([{ category: PermissionCategory
category: 'interaction', reason: string
reason: 'Control the player from this app' }]) // the card if (const interaction: CategoryAnswer
interaction.allow: boolean
allow) await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('#play').click: (options?: PositionOptions | undefined) => Promise<void>
click() // covered, no second card
const const result: FrameFetchResult
result = await const frame: Frame
frame.fetch: (url: string, init?: FrameFetchOptions | undefined) => Promise<FrameFetchResult>
fetch('https://example.org/api/catalog.json') // a Network row on a card of its own const result: FrameFetchResult
result.status: number
status // reached only when the render proxy served the call} catch (var error: unknown
error) { const { const message: string
message } = var error: unknown
error as interface Error
Error if (function isLocatorDenied(error: unknown): boolean
isLocatorDenied(var error: unknown
error)) const message: string
message // 'frame.fetch: the user did not grant network on example.org', refused at the card else if (function isTerminalError(error: unknown): boolean
isTerminalError(var error: unknown
error)) const message: string
message // the render proxy's own refusal else throw var error: unknown
error}Before the card, @fkn/lib checks the attachment rules for a frame.fetch(), and each one refuses as a terminal LocatorDeniedError, see fetching as the frame. Past the card the request is the render proxy’s to serve, and the refusals it can answer with have their rows on every error.
A category row on the card offers one duration, the life of the tab: the broker keeps the grant in its own sessionStorage, per app origin, category and host. A denial is not stored. Not now and closing the card both start a 10,000 ms cooldown on the row that was refused, during which the call fails closed with frame.fetch: the user did not grant network on <host> and no card is drawn. The message opens with the operation, so match it with includes rather than startsWith.
Asking well
Section titled “Asking well”Ask when the user understands why. We recommend running frame.request() or ensure() from the click that turns a feature on, with a reason that names what they get. A sheet on page load reads as noise and gets denied:
const enableControls: HTMLButtonElement
enableControls.HTMLButtonElement.addEventListener<"click">(type: "click", listener: (this: HTMLButtonElement, ev: PointerEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
addEventListener('click', async () => { const [const grant: CategoryAnswer
grant] = await const frame: extension.Frame
frame.function request(requests: extension.CategoryRequest[]): Promise<extension.CategoryGrant[]>
Asks for whole categories on this attachment's hosts, before any call. Resolves per request, in
the order they were asked; a refusal is allow: false, never a rejection. An ask naming a
category or key the registry cannot place on a row is a TypeError, thrown before it is sent.
request([{ category: extension.PermissionCategory
category: 'interaction', reason: string
reason: 'Control the player from this app' }]) // the sheet, from the user's own click if (!const grant: CategoryAnswer
grant.allow: boolean
allow) const enableControls: HTMLButtonElement
enableControls.Element.textContent: string | null
textContent = 'Player controls stay off' // the answer stands until the user changes it})The sheet appears from the user’s own click, and a refusal changes the button rather than asking again. Denials are normal, and a refusal is not retried. A plain Deny is stored for the session, so read the answer, tell the user what will not happen, and move on.
Ask for the categories a feature needs together rather than letting each call raise its own row. Do not pre-ask for severity 0: exists(), count(), isVisible(), videoElement(), addStyleTag() without noSanitize, attachFrame(), goto() and extension.fetch() without credentials to a public address never raise a sheet. The exception is a chain whose CSS probes the value attribute, which a severity-0 action refuses with count: selectors matching the value attribute need a consent-gated operation, see reading versus acting.
Do not cover the sheet. A prompt your own UI hides is dismissed as a denial remembered once, so the call fails and the next one asks again.
Errors you might see
Section titled “Errors you might see”The four you will meet most:
| Message | What happened |
|---|---|
Permission denied: <category> on <site> (<key> <scope>) | The user refused, dismissed the sheet, or a stored deny covers that category on that site. Not retried. |
The FKN WebExtension is installed but too old for this page | A category ask reached an extension below ABI 2. Read readExtensionHandshake() and fall back to the pre-category form. |
The FKN WebExtension is not installed, enabled or not exposed on this page. | No extension answered, and the install card produced none. |
permission rpc: the background answered with an unknown shape | The background’s reply had a shape the page side could not read. Retry, then reload the extension. |
Every other message has its row on every error. The rules for matching one are on handling errors.