foxlend 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/egress.js ADDED
@@ -0,0 +1,65 @@
1
+ // The egress allow list (docs/failure-modes.md E1-E15). judge() decides one
2
+ // request. The webRequest and proxy listeners in loans.ts call it.
3
+ import { matchesPattern, parsePattern } from "foxgate";
4
+ import { hostOf } from "./site.js";
5
+ const PASS = { block: false };
6
+ const NETWORK = new Set(["http:", "https:", "ws:", "wss:"]);
7
+ const LOCAL = new Set(["data:", "blob:", "about:"]);
8
+ const OWN_STORES = new Set(["firefox-default", "firefox-private"]);
9
+ const compiled = new Map();
10
+ // A pattern that no longer parses (the public suffix list changed) matches nothing (E16).
11
+ const matches = (host, pattern, publicSuffix) => {
12
+ let parsed = compiled.get(pattern);
13
+ if (!parsed) {
14
+ try {
15
+ parsed = parsePattern(pattern, publicSuffix);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ compiled.set(pattern, parsed);
21
+ }
22
+ return matchesPattern(host, parsed);
23
+ };
24
+ /** The host of a network URL, "local" for a URL that stays in the browser, or undefined for anything else. */
25
+ function hostOfUrl(url) {
26
+ try {
27
+ const parsed = new URL(url);
28
+ if (LOCAL.has(parsed.protocol))
29
+ return "local";
30
+ if (!NETWORK.has(parsed.protocol))
31
+ return undefined;
32
+ return hostOf(parsed.hostname);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Decide one request. `loans` undefined means the loan list could not be
40
+ * read: then every container other than the default and the private one is
41
+ * blocked (E13).
42
+ */
43
+ export function judge(request, loans, now, publicSuffix) {
44
+ const store = request.cookieStoreId;
45
+ if (!loans) {
46
+ if (store === undefined || OWN_STORES.has(store))
47
+ return PASS;
48
+ const host = hostOfUrl(request.url);
49
+ return { block: true, loanId: "unknown", reason: "no-state", ...(host && host !== "local" ? { host } : {}) };
50
+ }
51
+ const loan = store === undefined ? undefined : loans.find((l) => l.cookieStoreId === store);
52
+ if (!loan)
53
+ return PASS;
54
+ const host = hostOfUrl(request.url);
55
+ const block = (reason) => ({ block: true, loanId: loan.id, reason, ...(host && host !== "local" ? { host } : {}) });
56
+ if (loan.state === "revoking")
57
+ return block("revoking");
58
+ if (now >= loan.expiresAt)
59
+ return block("expired");
60
+ if (host === "local")
61
+ return PASS;
62
+ if (host === undefined)
63
+ return block("bad-url");
64
+ return loan.patterns.some((p) => matches(host, p, publicSuffix)) ? PASS : block("not-allowed");
65
+ }
@@ -0,0 +1,8 @@
1
+ export type FoxlendErrorCode = "bad-domain" | "bad-allow" | "bad-ttl" | "bad-url" | "bad-scope" | "lend-failed" | "revoke-failed" | "storage-error" | "setting-failed" | "no-host-access";
2
+ /** Every error that foxlend throws on purpose. Read `code`, not the message. */
3
+ export declare class FoxlendError extends Error {
4
+ readonly code: FoxlendErrorCode;
5
+ constructor(code: FoxlendErrorCode, message: string, options?: {
6
+ cause?: unknown;
7
+ });
8
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,9 @@
1
+ /** Every error that foxlend throws on purpose. Read `code`, not the message. */
2
+ export class FoxlendError extends Error {
3
+ code;
4
+ constructor(code, message, options) {
5
+ super(message, options);
6
+ this.name = "FoxlendError";
7
+ this.code = code;
8
+ }
9
+ }
@@ -0,0 +1,41 @@
1
+ import type { Host, PublicSuffix } from "foxgate";
2
+ import { type BrowserLike, type Listenable } from "./browser.js";
3
+ import { type BlockedRequest } from "./guard.js";
4
+ import { type LendOptions } from "./lend.js";
5
+ import { type RevokedEvent } from "./revoke.js";
6
+ import { type Loan } from "./state.js";
7
+ export interface FoxlendOptions {
8
+ /** The WebExtension `browser` object. */
9
+ browser: BrowserLike;
10
+ /** The foxgate host. foxlend adds one grant per loan and revokes it with the loan. */
11
+ host: Pick<Host, "addGrant" | "revokeGrant" | "grants">;
12
+ /** Default: withDefaultRule(browser.publicSuffix). Give foxgate the same object. */
13
+ publicSuffix?: PublicSuffix;
14
+ /** The clock, in ms since 1970. Default: Date.now. */
15
+ now?: () => number;
16
+ /** The longest loan, in ms. Default: 24 hours. */
17
+ maxTtlMs?: number;
18
+ /** Also block through proxy.onRequest. Default: true when `browser.proxy` exists. */
19
+ proxyLayer?: boolean;
20
+ /** Turn off network prediction (DNS prefetch, link prefetch) while a loan is active. Default: true. */
21
+ stopPrediction?: boolean;
22
+ /** Turn off WebRTC while a loan is active. Default: true. */
23
+ stopWebRtc?: boolean;
24
+ /** The browser.storage.local key. Default: "foxlend". */
25
+ storageKey?: string;
26
+ }
27
+ export interface Foxlend {
28
+ lend(options: LendOptions): Promise<Loan>;
29
+ /** Returns false when there is no such loan. Throws FoxlendError `revoke-failed` when a step fails. */
30
+ revoke(loan: Loan | string): Promise<boolean>;
31
+ listLoans(): Promise<Loan[]>;
32
+ /** Revoke loans whose time is over, and remove containers that no loan holds. foxlend runs it at start. */
33
+ sweep(): Promise<void>;
34
+ onBlocked: Listenable<BlockedRequest>;
35
+ onRevoked: Listenable<RevokedEvent>;
36
+ }
37
+ /**
38
+ * Call this at the top level of the background script, so Firefox can wake
39
+ * the event page for a request, an alarm, or a browser start.
40
+ */
41
+ export declare function createFoxlend(options: FoxlendOptions): Foxlend;
@@ -0,0 +1,49 @@
1
+ import { emitter } from "./browser.js";
2
+ import { attachGuard } from "./guard.js";
3
+ import { lendLoan } from "./lend.js";
4
+ import { revokeNow, sweepNow } from "./revoke.js";
5
+ import { withDefaultRule } from "./site.js";
6
+ import { loanStore } from "./state.js";
7
+ /**
8
+ * Call this at the top level of the background script, so Firefox can wake
9
+ * the event page for a request, an alarm, or a browser start.
10
+ */
11
+ export function createFoxlend(options) {
12
+ const { browser } = options;
13
+ const publicSuffix = options.publicSuffix ?? (browser.publicSuffix ? withDefaultRule(browser.publicSuffix) : undefined);
14
+ if (!publicSuffix)
15
+ throw new TypeError("foxlend needs a public suffix list: Firefox 153+ with the publicSuffix permission, or the publicSuffix option.");
16
+ const store = loanStore(browser, options.storageKey ?? "foxlend");
17
+ const blocked = emitter();
18
+ const revoked = emitter();
19
+ const ctx = {
20
+ browser,
21
+ host: options.host,
22
+ store,
23
+ now: options.now ?? Date.now,
24
+ publicSuffix,
25
+ maxTtlMs: options.maxTtlMs ?? 24 * 60 * 60 * 1000,
26
+ settings: [
27
+ ...(options.stopPrediction === false ? [] : ["networkPredictionEnabled"]),
28
+ ...(options.stopWebRtc === false ? [] : ["peerConnectionEnabled"]),
29
+ ],
30
+ };
31
+ attachGuard({ browser, store, now: ctx.now, publicSuffix, proxyLayer: options.proxyLayer ?? browser.proxy !== undefined, onBlocked: blocked.emit });
32
+ const revoke = (id, reason) => store.serial(() => revokeNow(ctx, id, reason, revoked.emit));
33
+ const sweep = () => store.serial(() => sweepNow(ctx, revoked.emit));
34
+ browser.alarms.onAlarm.addListener(({ name }) => {
35
+ if (name.startsWith("foxlend:"))
36
+ revoke(name.slice("foxlend:".length), "ttl").catch(() => undefined);
37
+ });
38
+ browser.permissions.onRemoved.addListener(() => void sweep().catch(() => undefined));
39
+ browser.runtime.onStartup.addListener(() => void sweep().catch(() => undefined));
40
+ sweep().catch(() => undefined);
41
+ return Object.freeze({
42
+ lend: (lendOptions) => lendLoan(ctx, lendOptions),
43
+ revoke: (loan) => revoke(typeof loan === "string" ? loan : loan.id, "user"),
44
+ listLoans: async () => structuredClone(await store.loans()),
45
+ sweep,
46
+ onBlocked: blocked.event,
47
+ onRevoked: revoked.event,
48
+ });
49
+ }
@@ -0,0 +1,28 @@
1
+ import type { PublicSuffix } from "foxgate";
2
+ import type { BrowserLike, ProxyInfo } from "./browser.js";
3
+ import { type BlockReason } from "./egress.js";
4
+ import type { LoanStore } from "./state.js";
5
+ /** A request that the guard stopped. */
6
+ export interface BlockedRequest {
7
+ loanId: string;
8
+ url: string;
9
+ host?: string;
10
+ /** The webRequest resource type, or "speculative" for a preconnect. */
11
+ type: string;
12
+ /** The page or script that sent the request, when Firefox knows it. */
13
+ initiator?: string;
14
+ layer: "webRequest" | "proxy";
15
+ reason: BlockReason;
16
+ at: number;
17
+ }
18
+ /** Port 9 is the discard port. Nothing answers, so the request fails. */
19
+ export declare const DEAD_PROXY: ProxyInfo;
20
+ export interface GuardOptions {
21
+ browser: BrowserLike;
22
+ store: LoanStore;
23
+ now: () => number;
24
+ publicSuffix: PublicSuffix;
25
+ proxyLayer: boolean;
26
+ onBlocked: (event: BlockedRequest) => void;
27
+ }
28
+ export declare function attachGuard({ browser, store, now, publicSuffix, proxyLayer, onBlocked }: GuardOptions): void;
package/dist/guard.js ADDED
@@ -0,0 +1,40 @@
1
+ import { judge } from "./egress.js";
2
+ /** Port 9 is the discard port. Nothing answers, so the request fails. */
3
+ export const DEAD_PROXY = { type: "socks", host: "127.0.0.1", port: 9, proxyDNS: true };
4
+ const OWN_STORES = new Set(["firefox-default", "firefox-private"]);
5
+ export function attachGuard({ browser, store, now, publicSuffix, proxyLayer, onBlocked }) {
6
+ const decide = (details, loans, layer) => {
7
+ // Firefox lets a request pass when a blocking listener throws, so an error blocks (E16).
8
+ let verdict;
9
+ try {
10
+ // A removed loan container is blocked as if it were still being revoked (L17).
11
+ const all = loans && [...loans, ...store.retired().map((id) => ({ id: "retired", cookieStoreId: id, patterns: [], expiresAt: 0, state: "revoking" }))];
12
+ verdict = judge(details, all, now(), publicSuffix);
13
+ }
14
+ catch {
15
+ verdict = { block: true, loanId: "unknown", reason: "error" };
16
+ }
17
+ if (!verdict.block)
18
+ return false;
19
+ // webRequest reports what it blocks. The proxy layer reports only what webRequest cannot see.
20
+ if (layer === "webRequest" || details.type === "speculative") {
21
+ const initiator = details.originUrl ?? details.documentUrl;
22
+ onBlocked({ loanId: verdict.loanId, url: details.url, ...(verdict.host ? { host: verdict.host } : {}), type: details.type, ...(initiator ? { initiator } : {}), layer, reason: verdict.reason, at: now() });
23
+ }
24
+ return true;
25
+ };
26
+ // Answer at once when the loans are in memory. Else wait for storage (E12).
27
+ function answer(details, layer, blocked) {
28
+ const id = details.cookieStoreId;
29
+ if (id === undefined || OWN_STORES.has(id))
30
+ return undefined;
31
+ const cached = store.cached();
32
+ if (cached)
33
+ return decide(details, cached, layer) ? blocked : undefined;
34
+ return store.load().then((loans) => (decide(details, loans, layer) ? blocked : undefined));
35
+ }
36
+ // Firefox refuses a cookieStoreId filter (E15), so listen to all URLs.
37
+ browser.webRequest.onBeforeRequest.addListener((details) => answer(details, "webRequest", { cancel: true }), { urls: ["<all_urls>"] }, ["blocking"]);
38
+ if (proxyLayer && browser.proxy)
39
+ browser.proxy.onRequest.addListener((details) => answer(details, "proxy", DEAD_PROXY), { urls: ["<all_urls>"] });
40
+ }
@@ -0,0 +1,10 @@
1
+ export { FoxlendError, type FoxlendErrorCode } from "./errors.js";
2
+ export { hostOf, loanPatterns, siteOf, withDefaultRule, type PatternInput, type PublicSuffixApi } from "./site.js";
3
+ export { planCopy, type Cookie, type CookieSetDetails, type CopyOptions, type SkippedCookie } from "./cookies.js";
4
+ export { judge, type BlockReason, type LoanState, type RequestInfo, type Verdict } from "./egress.js";
5
+ export { emitter, type BrowserEvent, type BrowserLike, type BrowserSetting, type BrowserSettingName, type ContextualIdentity, type Listenable, type ProxyInfo, type RequestDetails } from "./browser.js";
6
+ export { createFoxlend, type Foxlend, type FoxlendOptions } from "./foxlend.js";
7
+ export { DEAD_PROXY, type BlockedRequest } from "./guard.js";
8
+ export type { Loan } from "./state.js";
9
+ export { CONTAINER, type LendOptions } from "./lend.js";
10
+ export type { RevokedEvent } from "./revoke.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // The public API of foxlend.
2
+ export { FoxlendError } from "./errors.js";
3
+ export { hostOf, loanPatterns, siteOf, withDefaultRule } from "./site.js";
4
+ export { planCopy } from "./cookies.js";
5
+ export { judge } from "./egress.js";
6
+ export { emitter } from "./browser.js";
7
+ export { createFoxlend } from "./foxlend.js";
8
+ export { DEAD_PROXY } from "./guard.js";
9
+ export { CONTAINER } from "./lend.js";
package/dist/lend.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Host, PublicSuffix, Scope } from "foxgate";
2
+ import type { BrowserLike, BrowserSettingName } from "./browser.js";
3
+ import type { Loan, LoanStore } from "./state.js";
4
+ export interface LendOptions {
5
+ /** The host to lend, for example `www.example.com`. */
6
+ domain: string;
7
+ /** The foxgate scope of the grant: "read", "fill", "submit", or "pay". */
8
+ scope: Scope;
9
+ /** How long the loan lasts, in ms. */
10
+ ttlMs: number;
11
+ /** More hosts that pages in the loan may reach. Exact hosts or `*.` plus a host. */
12
+ allow?: string[];
13
+ /** The task URL to open. Default: `https://<domain>/`. */
14
+ url?: string;
15
+ /** Hide the loan tab from the tab strip. Needs the `tabHide` permission. */
16
+ hidden?: boolean;
17
+ /** "site" (default): the whole registrable domain. "host": the exact host only. */
18
+ match?: "site" | "host";
19
+ /** The tools that the foxgate grant allows. Default: every tool. */
20
+ tools?: string[];
21
+ }
22
+ export interface LoanContext {
23
+ browser: BrowserLike;
24
+ host: Pick<Host, "addGrant" | "revokeGrant" | "grants">;
25
+ store: LoanStore;
26
+ now: () => number;
27
+ publicSuffix: PublicSuffix;
28
+ maxTtlMs: number;
29
+ /** The browser-wide settings to turn off while a loan is active (E6, E17). */
30
+ settings: BrowserSettingName[];
31
+ }
32
+ export declare const CONTAINER: {
33
+ readonly prefix: "Agent · ";
34
+ readonly color: "purple";
35
+ readonly icon: "fingerprint";
36
+ };
37
+ export declare const alarmName: (loanId: string) => string;
38
+ /** True when the guard can see every request: the extension has access to all sites (E19). */
39
+ export declare const hasHostAccess: (ctx: LoanContext) => Promise<boolean>;
40
+ /** Give the browser-wide settings back when no loan is left (E6, E17, L15). */
41
+ export declare function releaseSettings(ctx: LoanContext): Promise<void>;
42
+ /** Close the loan tabs, clear and remove the container, and revoke the grant. */
43
+ export declare function teardown(ctx: LoanContext, loan: Pick<Loan, "cookieStoreId" | "grantId">): Promise<void>;
44
+ export declare function lendLoan(ctx: LoanContext, options: LendOptions): Promise<Loan>;
package/dist/lend.js ADDED
@@ -0,0 +1,170 @@
1
+ import { planCopy } from "./cookies.js";
2
+ import { judge } from "./egress.js";
3
+ import { FoxlendError } from "./errors.js";
4
+ import { hostOf, loanPatterns, siteOf } from "./site.js";
5
+ export const CONTAINER = { prefix: "Agent · ", color: "purple", icon: "fingerprint" };
6
+ export const alarmName = (loanId) => `foxlend:${loanId}`;
7
+ const SCOPES = ["read", "fill", "submit", "pay"];
8
+ const message = (error) => (error instanceof Error ? error.message : String(error));
9
+ const cookieUrl = (c) => `${c.secure ? "https" : "http"}://${c.domain.replace(/^\./, "")}${c.path}`;
10
+ const randomId = () => [...crypto.getRandomValues(new Uint8Array(12))].map((b) => b.toString(16).padStart(2, "0")).join("");
11
+ /** True when the guard can see every request: the extension has access to all sites (E19). */
12
+ export const hasHostAccess = (ctx) => ctx.browser.permissions.contains({ origins: ["<all_urls>"] }).catch(() => false);
13
+ /** Give the browser-wide settings back when no loan is left (E6, E17, L15). */
14
+ export async function releaseSettings(ctx) {
15
+ if ((await ctx.store.loans()).length > 0)
16
+ return;
17
+ for (const name of ctx.settings)
18
+ await ctx.browser.privacy?.network[name]?.clear({}).catch(() => false);
19
+ }
20
+ /** Turn each browser-wide setting off, or throw `setting-failed` (E18). */
21
+ async function holdSettings(ctx) {
22
+ for (const name of ctx.settings) {
23
+ const setting = ctx.browser.privacy?.network[name];
24
+ const refuse = (why) => new FoxlendError("setting-failed", `foxlend cannot turn off privacy.network.${name}: ${why}. Pass ${name === "peerConnectionEnabled" ? "stopWebRtc" : "stopPrediction"}: false to lend with it on.`);
25
+ if (!setting)
26
+ throw refuse("the privacy API is missing (add the privacy permission)");
27
+ const { levelOfControl } = await setting.get({});
28
+ if (!/^controll(able|ed)_by_this_extension$/.test(levelOfControl))
29
+ throw refuse(`it is ${levelOfControl}`);
30
+ if (!(await setting.set({ value: false }).catch(() => false)))
31
+ throw refuse("Firefox did not change it");
32
+ }
33
+ }
34
+ /** Close every tab of one container. Query again until none is left. */
35
+ async function closeTabs(b, id) {
36
+ for (let round = 0;; round++) {
37
+ const open = (await b.tabs.query({ cookieStoreId: id })).flatMap((t) => (t.id === undefined ? [] : [t.id]));
38
+ if (open.length === 0)
39
+ return;
40
+ if (round === 5)
41
+ throw new Error(`Tabs stay open in ${id}.`);
42
+ await b.tabs.remove(open);
43
+ }
44
+ }
45
+ /** Close the loan tabs, clear and remove the container, and revoke the grant. */
46
+ export async function teardown(ctx, loan) {
47
+ const b = ctx.browser;
48
+ const id = loan.cookieStoreId;
49
+ if (id) {
50
+ // From here on the guard blocks this container for good, also after a restart (L17).
51
+ await ctx.store.retire(id);
52
+ // Removing a container does not close its tabs (L5). Close them first.
53
+ await closeTabs(b, id);
54
+ // Only "not found" means gone. Any other error keeps the loan revoking (L13).
55
+ const exists = await b.contextualIdentities.get(id).then(() => true, (error) => {
56
+ if (/Invalid contextual identity/.test(message(error)))
57
+ return false;
58
+ throw error;
59
+ });
60
+ if (exists) {
61
+ // Firefox refuses serviceWorkers with cookieStoreId and then clears nothing (L6).
62
+ await b.browsingData.remove({ cookieStoreId: id }, { cookies: true, localStorage: true, indexedDB: true });
63
+ for (const c of await b.cookies.getAll({ storeId: id, partitionKey: {}, firstPartyDomain: null })) {
64
+ await b.cookies.remove({ url: cookieUrl(c), name: c.name, storeId: id, ...(c.partitionKey ? { partitionKey: c.partitionKey } : {}), ...(c.firstPartyDomain ? { firstPartyDomain: c.firstPartyDomain } : {}) });
65
+ }
66
+ await b.contextualIdentities.remove(id);
67
+ }
68
+ // A tab can open after the last query and before the remove (L17).
69
+ await closeTabs(b, id);
70
+ }
71
+ if (loan.grantId)
72
+ await ctx.host.revokeGrant(loan.grantId);
73
+ }
74
+ export async function lendLoan(ctx, options) {
75
+ const { browser: b, publicSuffix } = ctx;
76
+ if (!SCOPES.includes(options.scope))
77
+ throw new FoxlendError("bad-scope", `The scope must be one of ${SCOPES.join(", ")}.`);
78
+ const { ttlMs } = options;
79
+ if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > ctx.maxTtlMs) {
80
+ throw new FoxlendError("bad-ttl", `ttlMs must be a number above 0 and at most ${ctx.maxTtlMs}.`);
81
+ }
82
+ const match = options.match ?? "site";
83
+ const domain = hostOf(options.domain);
84
+ const site = siteOf(domain, publicSuffix);
85
+ const allow = options.allow ?? [];
86
+ const patterns = loanPatterns({ host: domain, match, allow, publicSuffix });
87
+ const url = options.url ?? `https://${domain}/`;
88
+ const probe = { id: "probe", cookieStoreId: "probe", patterns, expiresAt: Infinity, state: "active" };
89
+ if (judge({ url, type: "main_frame", cookieStoreId: "probe" }, [probe], 0, publicSuffix).block) {
90
+ throw new FoxlendError("bad-url", `${url} is not on the loan allow list (${patterns.join(", ")}).`);
91
+ }
92
+ return ctx.store.serial(async () => {
93
+ if (!(await hasHostAccess(ctx)))
94
+ throw new FoxlendError("no-host-access", "foxlend needs access to all sites, or the guard cannot see the loan's requests. Allow it in about:addons.");
95
+ // DNS prefetch and WebRTC are outside both guard layers (E6, E17). Without them off, no loan (E18).
96
+ try {
97
+ await holdSettings(ctx);
98
+ }
99
+ catch (error) {
100
+ await releaseSettings(ctx);
101
+ throw error;
102
+ }
103
+ const createdAt = ctx.now();
104
+ let loan = {
105
+ id: randomId(),
106
+ state: "creating",
107
+ patterns,
108
+ expiresAt: createdAt + ttlMs,
109
+ domain,
110
+ site,
111
+ scope: options.scope,
112
+ match,
113
+ allow,
114
+ url,
115
+ createdAt,
116
+ containerName: `${CONTAINER.prefix}${site}`,
117
+ hidden: false,
118
+ copied: 0,
119
+ skipped: [],
120
+ };
121
+ const put = async (patch) => {
122
+ loan = { ...loan, ...patch };
123
+ await ctx.store.save([...(await ctx.store.loans()).filter((l) => l.id !== loan.id), loan]);
124
+ };
125
+ // The record exists before the container, so a crash cannot hide a container (L2).
126
+ await put({});
127
+ try {
128
+ const container = await b.contextualIdentities.create({ name: loan.containerName, color: CONTAINER.color, icon: CONTAINER.icon });
129
+ await put({ cookieStoreId: container.cookieStoreId });
130
+ // Only read the default container (K10). firstPartyDomain null matches all (K9).
131
+ const source = await b.cookies.getAll({ storeId: "firefox-default", partitionKey: {}, firstPartyDomain: null });
132
+ const plan = planCopy(source, { host: domain, match, patterns, storeId: container.cookieStoreId, endsAt: loan.expiresAt, now: createdAt, publicSuffix });
133
+ const skipped = [...plan.skipped];
134
+ let copied = 0;
135
+ for (const details of plan.set) {
136
+ try {
137
+ await b.cookies.set(details);
138
+ copied++;
139
+ }
140
+ catch (error) {
141
+ skipped.push({ name: details.name, domain: details.domain ?? new URL(details.url).hostname, reason: "set-failed", message: message(error) });
142
+ }
143
+ }
144
+ await put({ grantRequested: true });
145
+ const grant = await ctx.host.addGrant({ scope: options.scope, domains: patterns, expiresAt: loan.expiresAt, ...(options.tools ? { tools: options.tools } : {}) });
146
+ // Save the grant ID before any other step, so the undo can revoke it (L16).
147
+ await put({ grantId: grant.id });
148
+ b.alarms.create(alarmName(loan.id), { when: loan.expiresAt });
149
+ await put({ state: "active", copied, skipped });
150
+ const tab = await b.tabs.create({ url, cookieStoreId: container.cookieStoreId, active: !options.hidden });
151
+ let hidden = false;
152
+ if (options.hidden && tab.id !== undefined)
153
+ hidden = await b.tabs.hide([tab.id]).then(() => true, () => false);
154
+ await put({ ...(tab.id === undefined ? {} : { tabId: tab.id }), hidden });
155
+ return structuredClone(loan);
156
+ }
157
+ catch (error) {
158
+ // Undo what is done (L3). If the undo fails too, the guard keeps blocking and the next sweep retries.
159
+ try {
160
+ await teardown(ctx, loan);
161
+ await ctx.store.save((await ctx.store.loans()).filter((l) => l.id !== loan.id));
162
+ await releaseSettings(ctx);
163
+ }
164
+ catch {
165
+ await put({ state: "revoking" }).catch(() => undefined);
166
+ }
167
+ throw new FoxlendError("lend-failed", `The loan for ${domain} failed and was undone: ${message(error)}`, { cause: error });
168
+ }
169
+ });
170
+ }
@@ -0,0 +1,15 @@
1
+ import type { Loan } from "./state.js";
2
+ import { type LoanContext } from "./lend.js";
3
+ /** Why a loan ended. "startup": a loan that never became active, or a revoke that failed before. */
4
+ export interface RevokedEvent {
5
+ loan: Loan;
6
+ reason: "user" | "ttl" | "startup" | "permission";
7
+ }
8
+ /** Revoke one loan. Call it inside store.serial(). */
9
+ export declare function revokeNow(ctx: LoanContext, id: string, reason: RevokedEvent["reason"], emit: (event: RevokedEvent) => void): Promise<boolean>;
10
+ /**
11
+ * Revoke loans whose time is over or that never became active, set the
12
+ * alarms again for the others, and remove foxlend containers that no loan
13
+ * holds. Call it inside store.serial().
14
+ */
15
+ export declare function sweepNow(ctx: LoanContext, emit: (event: RevokedEvent) => void): Promise<void>;
package/dist/revoke.js ADDED
@@ -0,0 +1,60 @@
1
+ import { alarmName, CONTAINER, hasHostAccess, releaseSettings, teardown } from "./lend.js";
2
+ import { FoxlendError } from "./errors.js";
3
+ const message = (error) => (error instanceof Error ? error.message : String(error));
4
+ /**
5
+ * The grant of a record that asked for one and never got its ID (L14): same
6
+ * scope, domains, and end time, and no other loan holds it.
7
+ */
8
+ async function lostGrant(ctx, loan, loans) {
9
+ const held = new Set(loans.map((l) => l.grantId));
10
+ const same = (domains) => JSON.stringify(domains) === JSON.stringify(loan.patterns);
11
+ return (await ctx.host.grants()).find((g) => !held.has(g.id) && g.scope === loan.scope && g.expiresAt === loan.expiresAt && same(g.domains))?.id;
12
+ }
13
+ /** Revoke one loan. Call it inside store.serial(). */
14
+ export async function revokeNow(ctx, id, reason, emit) {
15
+ const loans = await ctx.store.loans();
16
+ const found = loans.find((l) => l.id === id);
17
+ if (!found)
18
+ return false;
19
+ // The guard blocks the container from this line on (E11).
20
+ const grantId = found.grantId ?? (found.grantRequested ? await lostGrant(ctx, found, loans) : undefined);
21
+ const loan = { ...found, state: "revoking", ...(grantId ? { grantId } : {}) };
22
+ await ctx.store.save(loans.map((l) => (l.id === id ? loan : l)));
23
+ try {
24
+ await teardown(ctx, loan);
25
+ await ctx.browser.alarms.clear(alarmName(id));
26
+ }
27
+ catch (error) {
28
+ throw new FoxlendError("revoke-failed", `The loan for ${loan.domain} is not fully revoked: ${message(error)}. Its requests stay blocked, and the next start tries again.`, { cause: error });
29
+ }
30
+ await ctx.store.save((await ctx.store.loans()).filter((l) => l.id !== id));
31
+ await releaseSettings(ctx);
32
+ emit({ loan, reason });
33
+ return true;
34
+ }
35
+ /**
36
+ * Revoke loans whose time is over or that never became active, set the
37
+ * alarms again for the others, and remove foxlend containers that no loan
38
+ * holds. Call it inside store.serial().
39
+ */
40
+ export async function sweepNow(ctx, emit) {
41
+ const now = ctx.now();
42
+ // Without access to all sites the guard sees nothing, so no loan may stay (E19).
43
+ const blind = !(await hasHostAccess(ctx));
44
+ for (const loan of await ctx.store.loans()) {
45
+ const expired = loan.expiresAt <= now;
46
+ if (blind || expired || loan.state !== "active") {
47
+ await revokeNow(ctx, loan.id, blind ? "permission" : expired ? "ttl" : "startup", emit).catch(() => false);
48
+ }
49
+ else {
50
+ ctx.browser.alarms.create(alarmName(loan.id), { when: loan.expiresAt });
51
+ }
52
+ }
53
+ const held = new Set((await ctx.store.loans()).map((l) => l.cookieStoreId));
54
+ for (const c of await ctx.browser.contextualIdentities.query({})) {
55
+ const ours = c.name.startsWith(CONTAINER.prefix) && c.color === CONTAINER.color && c.icon === CONTAINER.icon;
56
+ if (ours && !held.has(c.cookieStoreId))
57
+ await teardown(ctx, { cookieStoreId: c.cookieStoreId }).catch(() => undefined);
58
+ }
59
+ await releaseSettings(ctx);
60
+ }
package/dist/site.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { type PublicSuffix } from "foxgate";
2
+ /** The parts of Firefox `browser.publicSuffix` (153+) that foxlend reads. */
3
+ export interface PublicSuffixApi {
4
+ getDomain(host: string): string | null | undefined;
5
+ getKnownSuffix(host: string): string | null | undefined;
6
+ }
7
+ export declare const IPV4: RegExp;
8
+ /**
9
+ * Wrap `browser.publicSuffix` with the default rule of the public suffix list
10
+ * ("*"): a host on a top-level domain that is not on the list (for example
11
+ * `.test` or `.localhost`) has the last two labels as its site. Firefox
12
+ * returns null for these hosts. Pass the result to foxgate too.
13
+ */
14
+ export declare function withDefaultRule(api: PublicSuffixApi): PublicSuffix;
15
+ /** Lowercase punycode host. Throws FoxlendError `bad-domain`. */
16
+ export declare function hostOf(input: string): string;
17
+ /** The registrable domain of a host. Throws FoxlendError `bad-domain` for a public suffix. */
18
+ export declare function siteOf(input: string, publicSuffix: PublicSuffix): string;
19
+ /** True when `host` is `domain` or a subdomain of it, by whole labels. */
20
+ export declare const within: (host: string, domain: string) => boolean;
21
+ export interface PatternInput {
22
+ host: string;
23
+ /** "site": the site and all its subdomains. "host": the exact host only. */
24
+ match: "site" | "host";
25
+ allow: string[];
26
+ publicSuffix: PublicSuffix;
27
+ }
28
+ /**
29
+ * The host patterns of a loan: the lent site (or host), then the allow list.
30
+ * The same strings are the egress allow list and the foxgate grant domains.
31
+ */
32
+ export declare function loanPatterns({ host, match, allow, publicSuffix }: PatternInput): string[];