summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/permission-manager.ts
blob: 3a24d034a36f1722ee9d995375263a8ec36983ac (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import {
	type PermissionReply,
	type PermissionRequest,
	PermissionService,
	type Ruleset,
} from "@dispatch/core";

/**
 * Listener fired exactly once per newly-created pending prompt. Used by
 * the notification dispatcher so that a permission request triggers a
 * push notification on the user's phone (without re-firing every time
 * the pending list mutates for an unrelated reason).
 */
export type PromptAddedListener = (prompt: {
	id: string;
	permission: string;
	description: string;
	metadata: Record<string, unknown>;
}) => void;

export class PermissionManager {
	private service = new PermissionService();
	private wsClients: Map<string, (data: unknown) => void> = new Map();
	private promptAddedListeners: Set<PromptAddedListener> = new Set();
	/** Ids that have already been broadcast as "added" — guards against re-emits. */
	private announcedPromptIds: Set<string> = new Set();

	registerClient(id: string, send: (data: unknown) => void): void {
		this.wsClients.set(id, send);
	}

	unregisterClient(id: string): void {
		this.wsClients.delete(id);
	}

	private broadcastPending(pending: Array<{ id: string; request: PermissionRequest }>): void {
		const message = {
			type: "permission-prompt",
			pending: pending.map((p) => ({ id: p.id, ...p.request })),
		};
		for (const send of this.wsClients.values()) {
			send(message);
		}

		// Detect newly-added prompts (ids present now that weren't before) and
		// fire `promptAddedListeners` once for each. Resolved/rejected ids are
		// pruned from `announcedPromptIds` so a future prompt that reuses an
		// id (theoretical, given the monotonic counter) would still notify.
		const currentIds = new Set(pending.map((p) => p.id));
		for (const id of this.announcedPromptIds) {
			if (!currentIds.has(id)) this.announcedPromptIds.delete(id);
		}
		for (const p of pending) {
			if (this.announcedPromptIds.has(p.id)) continue;
			this.announcedPromptIds.add(p.id);
			for (const listener of this.promptAddedListeners) {
				try {
					listener({
						id: p.id,
						permission: p.request.permission,
						description: p.request.description,
						metadata: p.request.metadata,
					});
				} catch (err) {
					console.warn(
						`[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`,
					);
				}
			}
		}
	}

	async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise<PermissionReply> {
		const promise = this.service.ask(request, rulesets);
		this.broadcastPending(this.service.getPending());
		return promise;
	}

	reply(id: string, reply: PermissionReply): void {
		this.service.reply(id, reply);
		this.broadcastPending(this.service.getPending());
	}

	getPending(): Array<{ id: string; request: PermissionRequest }> {
		return this.service.getPending();
	}

	getService(): PermissionService {
		return this.service;
	}

	/**
	 * Subscribe to "a new prompt is now pending" events. Fires once per
	 * unique prompt id, even if `broadcastPending` is called repeatedly
	 * for unrelated mutations. Returns an unsubscribe function.
	 */
	onPromptAdded(listener: PromptAddedListener): () => void {
		this.promptAddedListeners.add(listener);
		return () => {
			this.promptAddedListeners.delete(listener);
		};
	}
}