Skip to content

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:

app.ts
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.

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:

IdRow titleWhat accepting it covers
interactionInteract with <site>Clicking, typing and hovering on the site, through the session in the frame
storageRead data from <site>Reading the text, links and details the site shows
networkMake requests to <site> as youLoading pages and data from the site and sending changes back
evaluationRun 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.

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.

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:

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:

app.ts
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 row
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
('.byline').
textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent
() // no sheet, the same row already covers it

Both 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.

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.

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:

app.ts
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 fill

The 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.

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:

app.ts
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.

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:

app.ts
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.

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

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.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

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.

@parampredicate A function that accepts up to three arguments. The every method calls the predicate function for each element in the array until the predicate returns a value which is coercible to the Boolean value false, or until the end of the array.

@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.

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:

app.ts
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 row
const grant: PermissionGrant
grant
.
allow: boolean
allow
// true once the user allowed it

A 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.

Twenty keys exist. Each belongs to one category, and the table says what raises it and which row it lands on:

KeySeverityCategoryRaised by
read.text1Site datatextContent()
read.info1Site datagetAttribute()
read.visible0Site dataisVisible()
read.check0Site dataexists()
read.count0Site datacount()
act.click3Interactionclick()
act.type1Interactionfill()
act.hover1Interactionhover()
media.video0InteractionvideoElement()
media.appear0InteractionaddStyleTag()
embed.iframe0InteractionattachFrame()
embed.open0Interactiongoto()
media.appearU2EvaluationaddStyleTag({ noSanitize: true })
frame.fetchRead3Networkframe.fetch() with GET, HEAD, OPTIONS
frame.fetchWrite3Networkframe.fetch() with another method
network.fetch0Networkextension.fetch() without credentials
network.fetchCredentialed3Networkextension.fetch() with credentials: 'include'
network.fetchLocal3Networkextension.fetch() to a local network address
network.readCookie3Networkextension.cookies.get()
network.modifyRequestHeaders2Networkextension.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:

app.ts
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.

@parampredicate find calls predicate once for each element of the array, in ascending order, until it finds one where predicate returns true. If such an element is found, find immediately returns that element value. Otherwise, find returns undefined.

@paramthisArg If provided, it will be used as the this value for each invocation of predicate. If it is not provided, undefined is used instead.

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.

MDN Reference

document
.
ParentNode.querySelector<Element>(selectors: string): Element | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

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 it

The 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 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:

app.ts
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 row
const result: FrameFetchResult
result
.
status: number
status
// whatever example.org answered, which the receipt records

The 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.

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.

app.ts
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.

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:

app.ts
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.

MDN Reference

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

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.

The four you will meet most:

MessageWhat 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 pageA 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 shapeThe 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.