Skip to content

Permissions and consent

Every sensitive capability an app uses through the FKN extension is gated by an explicit user consent prompt: per app, per capability, per scope. Grants are stored on the user’s device, every use is recorded in a 30-day on-device activity log, and the user can revoke any grant at any time from the extension’s dashboard.

As an app developer you don’t implement any of this. You call the API; the extension prompts. Your job is to ask well.

The first time an action needs an ungranted capability, the extension shows a consent sheet describing the capability, the requesting app, and the exact element scope. The user chooses allow once, allow for this session, always allow, or deny. A denial rejects your call with an error; handle it gracefully.

Pass a reason to tell the user why you are asking:

await
const frame: Frame
frame
.
locator: (selector: string) => Locator
locator
('.controls')
.
getByText: (text: string) => Locator
getByText
('Play')
.
click: (options?: (OperationTimeoutOptions & {
position?: {
x?: number | undefined;
y?: number | undefined;
} | undefined;
}) | undefined) => Promise<void>
click
({
reason?: string | undefined
reason
: 'Start playback when you press play in this app' })

ensure(operation, options?) resolves the permission an operation needs without running it. Use it to front-load consent at a natural moment (app start, player mount) instead of interrupting mid-interaction:

await
const frame: Frame
frame
.
locator: (selector: string) => Locator
locator
('.controls').
ensure: (operation: "click" | "fill" | "hover" | "textContent" | "isVisible" | "count" | "exists" | "getAttribute" | "videoElement", options?: {
reason?: string;
timeout?: number;
subtree?: boolean;
} & Record<string, unknown>) => Promise<void>
ensure
('click')

ensure(operation, { subtree: true }) asks for the capability on an element and everything inside it. One prompt covers all descendant locators; the prompt marks the request with a “Whole area” badge so the user knows it is broad. On the frame root it becomes the whole-page wildcard.

// One prompt: "click, everything inside .controls"
await
const frame: Frame
frame
.
locator: (selector: string) => Locator
locator
('.controls').
ensure: (operation: "click" | "fill" | "hover" | "textContent" | "isVisible" | "count" | "exists" | "getAttribute" | "videoElement", options?: {
reason?: string;
timeout?: number;
subtree?: boolean;
} & Record<string, unknown>) => Promise<void>
ensure
('click', {
subtree?: boolean | undefined
subtree
: true })
// Covered: no further prompts, each use audit-logged as "covered"
await
const frame: Frame
frame
.
locator: (selector: string) => Locator
locator
('.controls').
locator: (selector: string) => Locator
locator
('#play').
click: (options?: (OperationTimeoutOptions & {
position?: {
x?: number | undefined;
y?: number | undefined;
} | undefined;
}) | undefined) => Promise<void>
click
()
await
const frame: Frame
frame
.
locator: (selector: string) => Locator
locator
('.controls').
locator: (selector: string) => Locator
locator
('#mute').
click: (options?: (OperationTimeoutOptions & {
position?: {
x?: number | undefined;
y?: number | undefined;
} | undefined;
}) | undefined) => Promise<void>
click
()

Covered actions still appear in the user’s activity log, attributed to the covering grant. An exact grant or denial on a deeper element always beats a covering one.

permissions.request() asks for several capabilities up front in a single consent sheet. Anything already decided resolves without showing a sheet.

import {
const permissions: {
request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>;
}
permissions
} from '@fkn/lib'
const
const grants: PermissionGrant[]
grants
= await
const permissions: {
request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>;
}
permissions
.
request: (requests: PermissionRequest[]) => Promise<PermissionGrant[]>
request
([
{
key: "act.click" | "read.text" | "read.info" | "read.visible" | "read.check" | "read.count" | "act.type" | "act.hover" | "media.video" | "media.appear" | "media.appearU" | "embed.iframe" | "embed.open" | "network.fetch" | "network.fetchCredentialed" | "network.readCookie" | "network.modifyRequestHeaders"
key
: 'act.click',
scope?: string | undefined
scope
: '.controls *',
reason?: string | undefined
reason
: 'Control the player' },
{
key: "act.click" | "read.text" | "read.info" | "read.visible" | "read.check" | "read.count" | "act.type" | "act.hover" | "media.video" | "media.appear" | "media.appearU" | "embed.iframe" | "embed.open" | "network.fetch" | "network.fetchCredentialed" | "network.readCookie" | "network.modifyRequestHeaders"
key
: 'read.text',
scope?: string | undefined
scope
: '.title',
reason?: string | undefined
reason
: 'Show the current title' },
])
for (const
const grant: PermissionGrant
grant
of
const grants: PermissionGrant[]
grants
) {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
const grant: PermissionGrant
grant
.
key: "act.click" | "read.text" | "read.info" | "read.visible" | "read.check" | "read.count" | "act.type" | "act.hover" | "media.video" | "media.appear" | "media.appearU" | "embed.iframe" | "embed.open" | "network.fetch" | "network.fetchCredentialed" | "network.readCookie" | "network.modifyRequestHeaders"
key
,
const grant: PermissionGrant
grant
.
scope: string
scope
,
const grant: PermissionGrant
grant
.
allow: boolean
allow
)
}

Capabilities carry a severity. Low-severity capabilities that expose no user data (for example embedding an iframe, or a cookie-less cross-origin fetch) are granted automatically but still logged. Anything touching the user’s session, cookies, or page content always prompts.

  • Ask at a moment the user understands, and use reason so the prompt reads as part of your flow.
  • Prefer one area grant over a dozen per-control prompts for dense UI like players.
  • Expect denials. A rejected promise is an answer, not an error state to retry in a loop.