@xenosystem/blocks 0.2.1 → 0.3.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.
@@ -0,0 +1,1003 @@
1
+ // src/trust/consent/panel.ts
2
+ import { createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import {
5
+ isRecord,
6
+ bindConfig
7
+ } from "@xenosystem/panel-sdk";
8
+
9
+ // src/trust/consent/types.ts
10
+ function allowedDurations(request) {
11
+ return request.remember === "forbidden" ? ["once"] : ["once", "session", "always"];
12
+ }
13
+ function selectableScopes(request) {
14
+ return request.scopes.filter((s) => !s.alreadyGranted);
15
+ }
16
+ function effectiveRisk(request) {
17
+ const order = ["low", "medium", "high"];
18
+ let highest = request.risk;
19
+ for (const scope of request.scopes ?? []) {
20
+ if (scope.risk && order.indexOf(scope.risk) > order.indexOf(highest)) highest = scope.risk;
21
+ }
22
+ return highest;
23
+ }
24
+ function isExpired(request, now) {
25
+ return request.expiresAt !== void 0 && now >= request.expiresAt;
26
+ }
27
+ function isConsentRequest(value) {
28
+ if (!value || typeof value !== "object") return false;
29
+ const v = value;
30
+ if (v.ask !== void 0 && v.ask !== "permission") return false;
31
+ return typeof v.id === "string" && typeof v.title === "string" && Array.isArray(v.scopes) && typeof v.risk === "string" && typeof v.requestedAt === "number";
32
+ }
33
+ function autoDeny(request, reason) {
34
+ return { id: request.id, decision: "deny", scope: "once", reason };
35
+ }
36
+
37
+ // src/trust/consent/elicitation.ts
38
+ var ELICITATION_SCHEMA = "xeno.elicitation@1";
39
+ var ELICITATION_RESULT_SCHEMA = "xeno.elicitationresult@1";
40
+ function elicitationAsk(value) {
41
+ return value.ask ?? "permission";
42
+ }
43
+ function isPermissionElicitation(value) {
44
+ return elicitationAsk(value) === "permission";
45
+ }
46
+ function elicitationOptions(value) {
47
+ const options = value.options;
48
+ return Array.isArray(options) ? options : [];
49
+ }
50
+ function findOption(value, optionId) {
51
+ if (optionId === null) return void 0;
52
+ return elicitationOptions(value).find((option) => option.id === optionId);
53
+ }
54
+ function maxTextLength(value) {
55
+ const max = value.maxLength;
56
+ return typeof max === "number" && Number.isFinite(max) && max > 0 ? max : null;
57
+ }
58
+ function hasElicitationBase(v) {
59
+ return typeof v.id === "string" && typeof v.title === "string" && typeof v.risk === "string" && typeof v.requestedAt === "number";
60
+ }
61
+ function areOptions(value, required) {
62
+ if (value === void 0) return !required;
63
+ if (!Array.isArray(value) || required && value.length === 0) return false;
64
+ return value.every(
65
+ (o) => !!o && typeof o === "object" && typeof o.id === "string" && typeof o.label === "string"
66
+ );
67
+ }
68
+ function isElicitation(value) {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
70
+ const v = value;
71
+ const ask = v.ask;
72
+ if (ask === void 0 || ask === "permission") return isConsentRequest(value);
73
+ if (!hasElicitationBase(v)) return false;
74
+ switch (ask) {
75
+ case "text":
76
+ return areOptions(v.options, false);
77
+ case "path":
78
+ return (v.pathKind === "file" || v.pathKind === "directory") && areOptions(v.options, false);
79
+ case "choice":
80
+ return areOptions(v.options, true);
81
+ default:
82
+ return false;
83
+ }
84
+ }
85
+ function isElicitationAnswer(value) {
86
+ return !!value && typeof value === "object" && value.answered === true;
87
+ }
88
+ function isElicitationCancelled(value) {
89
+ return !!value && typeof value === "object" && value.answered === false;
90
+ }
91
+ function autoCancel(request, reason) {
92
+ const ask = elicitationAsk(request);
93
+ return {
94
+ id: request.id,
95
+ // A permission never reaches here — the controller routes those to `autoDeny` — but the type
96
+ // must still be total, and `'text'` is the inert choice: a cancellation carries no answer for
97
+ // ANY ask, so the field is a label, never a behaviour.
98
+ ask: ask === "permission" ? "text" : ask,
99
+ answered: false,
100
+ reason
101
+ };
102
+ }
103
+
104
+ // src/trust/consent/controller.ts
105
+ var ConsentController = class {
106
+ host;
107
+ listeners = /* @__PURE__ */ new Set();
108
+ now;
109
+ setTimer;
110
+ clearTimer;
111
+ perScope;
112
+ defaultDuration;
113
+ queue = [];
114
+ selected = /* @__PURE__ */ new Set();
115
+ duration;
116
+ /** What the user has typed for the head question. Discarded whenever the head changes. */
117
+ draftText = "";
118
+ /** The option highlighted for the head question. Highlighting is not answering. */
119
+ selectedOptionId = null;
120
+ expiryTimer = null;
121
+ /** Ids already decided, so a re-delivery cannot produce a second decision. */
122
+ resolved = /* @__PURE__ */ new Set();
123
+ snapshot = null;
124
+ constructor(options) {
125
+ this.host = options.host;
126
+ this.now = options.host.now ?? (() => Date.now());
127
+ this.setTimer = options.host.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
128
+ this.clearTimer = options.host.clearTimer ?? ((h) => clearTimeout(h));
129
+ this.perScope = options.perScope !== false;
130
+ this.defaultDuration = options.defaultDuration ?? "once";
131
+ this.duration = this.defaultDuration;
132
+ }
133
+ /* ── Subscription ──────────────────────────────────────────────────────── */
134
+ subscribe = (listener) => {
135
+ this.listeners.add(listener);
136
+ return () => this.listeners.delete(listener);
137
+ };
138
+ getState = () => {
139
+ if (!this.snapshot) {
140
+ const current = this.queue[0] ?? null;
141
+ this.snapshot = {
142
+ current,
143
+ queued: this.queue.slice(1),
144
+ pending: this.queue.length,
145
+ selectedScopeIds: [...this.selected],
146
+ duration: this.duration,
147
+ expiresInMs: current?.expiresAt === void 0 ? null : Math.max(0, current.expiresAt - this.now()),
148
+ draftText: this.draftText,
149
+ selectedOptionId: this.selectedOptionId,
150
+ canSubmit: this.answerFor(current) !== null
151
+ };
152
+ }
153
+ return this.snapshot;
154
+ };
155
+ notify() {
156
+ this.snapshot = null;
157
+ for (const listener of this.listeners) listener();
158
+ }
159
+ /* ── Queue ─────────────────────────────────────────────────────────────── */
160
+ /**
161
+ * Enqueue a request.
162
+ *
163
+ * Requests are **ordered and individually resolvable**. They are never merged, never deduplicated
164
+ * into one "allow all", and a second request does not supersede the first — bulk approval is how
165
+ * consent fatigue turns into a security hole.
166
+ *
167
+ * @returns `true` if enqueued; `false` if already pending or already decided.
168
+ */
169
+ enqueue(request) {
170
+ if (!isElicitation(request)) return false;
171
+ if (this.resolved.has(request.id)) return false;
172
+ if (this.queue.some((r) => r.id === request.id)) return false;
173
+ if (isExpired(request, this.now())) {
174
+ this.settleUnanswered(request, "expired");
175
+ return false;
176
+ }
177
+ const wasEmpty = this.queue.length === 0;
178
+ this.queue.push(request);
179
+ this.queue.sort((a, b) => a.requestedAt - b.requestedAt || (a.id < b.id ? -1 : 1));
180
+ if (wasEmpty || this.queue[0]?.id === request.id) this.resetSelection();
181
+ this.armExpiry();
182
+ this.notify();
183
+ return true;
184
+ }
185
+ /** Enqueue several. */
186
+ enqueueMany(requests) {
187
+ let added = 0;
188
+ for (const request of requests) if (this.enqueue(request)) added += 1;
189
+ return added;
190
+ }
191
+ /**
192
+ * Withdraw a request the host no longer needs an answer to.
193
+ *
194
+ * Emits `superseded` so the host's awaiting promise resolves — a withdrawn request that resolves
195
+ * nothing leaves the caller hanging forever.
196
+ */
197
+ withdraw(id) {
198
+ const index = this.queue.findIndex((r) => r.id === id);
199
+ if (index < 0) return false;
200
+ const [request] = this.queue.splice(index, 1);
201
+ if (request) this.settleUnanswered(request, "superseded");
202
+ this.resetSelection();
203
+ this.armExpiry();
204
+ this.notify();
205
+ return true;
206
+ }
207
+ /**
208
+ * Selection state for the head of the queue.
209
+ *
210
+ * 🔴 **Called on every head change, and that is what stops a draft leaking between questions.**
211
+ * A name typed for one ask must never arrive as the answer to the next one — the ids differ, so
212
+ * the host would attribute it to a question the user never saw.
213
+ */
214
+ resetSelection() {
215
+ const current = this.queue[0];
216
+ const permission = current && isPermissionElicitation(current) ? current : null;
217
+ this.selected = new Set(permission ? selectableScopes(permission).map((s) => s.id) : []);
218
+ const durations = permission ? allowedDurations(permission) : ["once"];
219
+ this.duration = durations.includes(this.defaultDuration) ? this.defaultDuration : "once";
220
+ this.draftText = current && !permission ? String(current.initialValue ?? "") : "";
221
+ const preferred = current && elicitationAsk(current) === "choice" ? current.defaultOptionId ?? null : null;
222
+ this.selectedOptionId = typeof preferred === "string" && findOption(current, preferred) ? preferred : null;
223
+ }
224
+ /* ── Expiry ────────────────────────────────────────────────────────────── */
225
+ /**
226
+ * Arm a timer for the head request's expiry.
227
+ *
228
+ * **Expiry denies.** There is no configuration that makes a timeout allow — a grant nobody was
229
+ * present to give is not a grant.
230
+ */
231
+ armExpiry() {
232
+ if (this.expiryTimer !== null) {
233
+ this.clearTimer(this.expiryTimer);
234
+ this.expiryTimer = null;
235
+ }
236
+ const current = this.queue[0];
237
+ if (!current?.expiresAt) return;
238
+ const delay = Math.max(0, current.expiresAt - this.now());
239
+ this.expiryTimer = this.setTimer(() => {
240
+ this.expiryTimer = null;
241
+ this.expireDue();
242
+ }, delay);
243
+ }
244
+ /**
245
+ * Settle every request whose deadline has passed — permissions as a deny, questions as a
246
+ * cancellation.
247
+ *
248
+ * Sweeps the WHOLE queue, not just the head: a request waiting behind a slow decision can expire
249
+ * while it is still queued, and showing it afterwards would ask for an answer nobody can use.
250
+ *
251
+ * @returns How many expired.
252
+ */
253
+ expireDue() {
254
+ const now = this.now();
255
+ const due = this.queue.filter((r) => isExpired(r, now));
256
+ if (due.length === 0) {
257
+ this.armExpiry();
258
+ return 0;
259
+ }
260
+ this.queue = this.queue.filter((r) => !isExpired(r, now));
261
+ for (const request of due) this.settleUnanswered(request, "expired");
262
+ this.resetSelection();
263
+ this.armExpiry();
264
+ this.notify();
265
+ return due.length;
266
+ }
267
+ /* ── Selection ─────────────────────────────────────────────────────────── */
268
+ /** Toggle one scope of the current request. Refused when the head is not a permission. */
269
+ toggleScope(scopeId, selected) {
270
+ const current = this.currentPermission();
271
+ if (!current || !this.perScope) return false;
272
+ if (!selectableScopes(current).some((s) => s.id === scopeId)) return false;
273
+ const next = selected ?? !this.selected.has(scopeId);
274
+ if (next) this.selected.add(scopeId);
275
+ else this.selected.delete(scopeId);
276
+ this.notify();
277
+ return true;
278
+ }
279
+ /** Choose how long the grant lasts. Refuses a duration the request forbids. */
280
+ setDuration(duration) {
281
+ const current = this.currentPermission();
282
+ if (!current) return false;
283
+ if (!allowedDurations(current).includes(duration)) return false;
284
+ this.duration = duration;
285
+ this.notify();
286
+ return true;
287
+ }
288
+ /* ── Drafting an answer ────────────────────────────────────────────────── */
289
+ /**
290
+ * Record what the user has typed.
291
+ *
292
+ * ⚠️ **A draft is not an answer and never leaves the panel on its own.** Only `submit()` emits,
293
+ * and every other settlement path discards this — so a half-typed name cannot escape through a
294
+ * timeout.
295
+ *
296
+ * @param value - The current field contents.
297
+ * @returns `true` when the head is a question that takes a value.
298
+ */
299
+ setDraftText(value) {
300
+ const current = this.currentQuestion();
301
+ if (!current) return false;
302
+ if (typeof value !== "string") return false;
303
+ this.draftText = value;
304
+ this.notify();
305
+ return true;
306
+ }
307
+ /**
308
+ * Highlight one of the offered options.
309
+ *
310
+ * For a `choice` this IS the answer-in-progress; for `text`/`path` it is a shortcut that also
311
+ * fills the field. Either way nothing is emitted — highlighting is not answering.
312
+ *
313
+ * @param optionId - The option, or `null` to clear.
314
+ * @returns `true` when the option belongs to the current question.
315
+ */
316
+ selectOption(optionId) {
317
+ const current = this.currentQuestion();
318
+ if (!current) return false;
319
+ if (optionId === null) {
320
+ this.selectedOptionId = null;
321
+ this.notify();
322
+ return true;
323
+ }
324
+ const option = findOption(current, optionId);
325
+ if (!option) return false;
326
+ this.selectedOptionId = optionId;
327
+ if (elicitationAsk(current) !== "choice" && !option.requiresText) this.draftText = option.label;
328
+ this.notify();
329
+ return true;
330
+ }
331
+ /* ── Decisions ─────────────────────────────────────────────────────────── */
332
+ /**
333
+ * Allow the current request.
334
+ *
335
+ * The ONLY path in this controller that produces `decision: 'allow'`, and it requires a call from
336
+ * a user gesture. Refuses when nothing is selected — an "Allow" that grants nothing is a button
337
+ * that lies about what it did.
338
+ */
339
+ allow() {
340
+ const current = this.currentPermission();
341
+ if (!current) return false;
342
+ const selectable = selectableScopes(current);
343
+ const granted = this.perScope ? [...this.selected] : selectable.map((s) => s.id);
344
+ if (selectable.length > 0 && granted.length === 0) return false;
345
+ this.resolveHead({
346
+ id: current.id,
347
+ decision: "allow",
348
+ scope: this.duration,
349
+ reason: "user",
350
+ grantedScopeIds: granted
351
+ });
352
+ return true;
353
+ }
354
+ /**
355
+ * Deny the current request.
356
+ *
357
+ * ⚠️ **Permissions only.** "Deny" is a permission verb; a question is refused with
358
+ * {@link ConsentController.dismiss}, which is what `cancel_request` calls. Returning `false`
359
+ * here rather than quietly cancelling keeps the two refusals distinguishable to a caller.
360
+ */
361
+ deny() {
362
+ const current = this.currentPermission();
363
+ if (!current) return false;
364
+ this.resolveHead({ id: current.id, decision: "deny", scope: "once", reason: "user" });
365
+ return true;
366
+ }
367
+ /**
368
+ * Submit the answer to the current QUESTION.
369
+ *
370
+ * 🔴 **The only path in this controller that produces an answer**, and the exact counterpart of
371
+ * `allow()`: it requires a user gesture and it refuses to emit something the user did not
372
+ * supply. An empty text answer, a choice with nothing chosen, or a `requiresText` option with no
373
+ * value are all refusals — for the same reason `allow()` refuses to grant nothing. A control
374
+ * that reports success while carrying nothing is a control that lies about what it did.
375
+ *
376
+ * @returns `true` when an answer was emitted.
377
+ */
378
+ submit() {
379
+ const current = this.currentQuestion();
380
+ if (!current) return false;
381
+ const answer = this.answerFor(current);
382
+ if (answer === null) return false;
383
+ this.resolveHead(answer);
384
+ return true;
385
+ }
386
+ /**
387
+ * The user dismissed the prompt without answering.
388
+ *
389
+ * Resolves as **deny** for a permission and as a **cancellation** for a question — never as an
390
+ * allow, never as an answer, and never as "ask again later": an unanswered request that stays
391
+ * pending forever blocks the host that is awaiting it.
392
+ */
393
+ dismiss() {
394
+ const current = this.queue[0];
395
+ if (!current) return false;
396
+ this.queue.shift();
397
+ this.settleUnanswered(current, "dismissed");
398
+ this.resetSelection();
399
+ this.armExpiry();
400
+ this.notify();
401
+ return true;
402
+ }
403
+ /* ── Emission ──────────────────────────────────────────────────────────── */
404
+ /** The head of the queue when it is a permission. */
405
+ currentPermission() {
406
+ const current = this.queue[0];
407
+ return current && isPermissionElicitation(current) ? current : null;
408
+ }
409
+ /** The head of the queue when it is a question rather than a permission. */
410
+ currentQuestion() {
411
+ const current = this.queue[0];
412
+ if (!current || isPermissionElicitation(current)) return null;
413
+ return current;
414
+ }
415
+ /**
416
+ * The answer the current draft would produce, or `null` when it would produce none.
417
+ *
418
+ * ⚠️ **Pure, and shared with `getState().canSubmit`.** One predicate drives both the guard and
419
+ * the button's enabled state, so an enabled button cannot be a button that refuses — the class of
420
+ * bug where an affordance and its guard disagree has no representation here.
421
+ */
422
+ answerFor(current) {
423
+ if (!current || isPermissionElicitation(current)) return null;
424
+ const ask = elicitationAsk(current);
425
+ const value = this.draftText;
426
+ const max = maxTextLength(current);
427
+ if (max !== null && value.length > max) return null;
428
+ const optionId = this.selectedOptionId;
429
+ if (ask === "choice") {
430
+ if (optionId === null) return null;
431
+ const option = findOption(current, optionId);
432
+ if (!option) return null;
433
+ if (option.requiresText && value.trim() === "") return null;
434
+ const answer = {
435
+ id: current.id,
436
+ ask: "choice",
437
+ answered: true,
438
+ reason: "user",
439
+ optionId
440
+ };
441
+ return option.requiresText ? { ...answer, value } : answer;
442
+ }
443
+ const allowEmpty = ask === "text" && current.allowEmpty === true;
444
+ if (value.trim() === "" && !allowEmpty) return null;
445
+ return {
446
+ id: current.id,
447
+ ask: ask === "path" ? "path" : "text",
448
+ answered: true,
449
+ reason: "user",
450
+ value,
451
+ ...optionId !== null && findOption(current, optionId) ? { optionId } : {}
452
+ };
453
+ }
454
+ /**
455
+ * Settle a request the user did not answer.
456
+ *
457
+ * 🔴 **The generalised safe default lives here, and it is kind-dependent BY NECESSITY.** A
458
+ * permission's safe outcome is `deny`; a question's safe outcome is the absence of an answer.
459
+ * There is no third branch and no configuration that reaches one.
460
+ */
461
+ settleUnanswered(request, reason) {
462
+ if (isPermissionElicitation(request)) this.emitResolution(autoDeny(request, reason));
463
+ else this.emitResolution(autoCancel(request, reason));
464
+ }
465
+ resolveHead(resolution) {
466
+ this.queue.shift();
467
+ this.emitResolution(resolution);
468
+ this.resetSelection();
469
+ this.armExpiry();
470
+ this.notify();
471
+ }
472
+ /**
473
+ * Emit one resolution, on exactly one port.
474
+ *
475
+ * ⚠️ **The two output ports PARTITION the outcomes** — a permission answer only ever appears on
476
+ * `decision`, a question result only ever on `result`. Emitting a permission on both would let a
477
+ * host that wired both ports apply the same grant twice, which on a security surface is a defect
478
+ * to design out rather than to document.
479
+ */
480
+ emitResolution(resolution) {
481
+ if (this.resolved.has(resolution.id)) return;
482
+ this.resolved.add(resolution.id);
483
+ const isPermissionDecision = "decision" in resolution;
484
+ this.host.emit(isPermissionDecision ? "decision" : "result", resolution);
485
+ }
486
+ /** Has this request already been answered? */
487
+ isResolved(id) {
488
+ return this.resolved.has(id);
489
+ }
490
+ /**
491
+ * Pending requests, in order.
492
+ *
493
+ * ⚠️ Widened from `XenoConsentRequest[]` with the question kinds. A host that only enqueues
494
+ * permissions only ever gets permissions back; narrow with `isPermissionElicitation()`.
495
+ */
496
+ pending() {
497
+ return [...this.queue];
498
+ }
499
+ /* ── Lifecycle ─────────────────────────────────────────────────────────── */
500
+ /**
501
+ * Serialize — deliberately empty.
502
+ *
503
+ * **A pending decision must never survive a reload.** Restoring one would show a prompt whose
504
+ * host is long gone, and any answer would resolve nothing.
505
+ */
506
+ serialize() {
507
+ return {};
508
+ }
509
+ /** Restore — a no-op, for the same reason. */
510
+ deserialize() {
511
+ }
512
+ /**
513
+ * Tear down.
514
+ *
515
+ * Every still-pending request is settled — permissions **denied**, questions **cancelled** — so a
516
+ * host awaiting one is never left hanging by a panel that simply vanished.
517
+ *
518
+ * 🔴 **Disposal is not the same event as unmounting, and the difference matters for a question.**
519
+ * `render()`/`unrender()` only move the DOM; the controller and its queue outlive them, so
520
+ * hiding a panel tab does NOT answer or cancel anything and a half-typed draft survives being
521
+ * hidden. `dispose()` is the panel going away for good, and THAT is what settles the queue. A
522
+ * host that tears the panel down mid-question receives `answered: false` with reason
523
+ * `'dismissed'` — never an empty string, which it could not distinguish from a deliberate blank.
524
+ */
525
+ dispose() {
526
+ if (this.expiryTimer !== null) {
527
+ this.clearTimer(this.expiryTimer);
528
+ this.expiryTimer = null;
529
+ }
530
+ for (const request of this.queue) this.settleUnanswered(request, "dismissed");
531
+ this.queue = [];
532
+ this.listeners.clear();
533
+ }
534
+ };
535
+
536
+ // src/trust/consent/manifest.ts
537
+ import { WELL_KNOWN_PORT_SCHEMAS } from "@xenosystem/panel-sdk";
538
+ var CONSENT_PANEL_ID = "xeno.core.consent";
539
+ var consentManifest = {
540
+ id: CONSENT_PANEL_ID,
541
+ version: "0.1.0",
542
+ title: "Consent",
543
+ icon: "shield-check",
544
+ description: "A blocking, request-scoped, decision-returning surface. Queued requests are answered individually, decisions are explicit, and scope and duration are visible in the answer. Beyond permissions it elicits text, a path, or one of a closed set of choices \u2014 none of which auto-resolve: a permission nobody answered denies, and a question nobody answered yields no answer at all.",
545
+ defaultSlot: "inspector",
546
+ inputs: [
547
+ {
548
+ id: "requests",
549
+ name: "Requests",
550
+ type: "object",
551
+ schema: WELL_KNOWN_PORT_SCHEMAS.CONSENT_REQUEST,
552
+ description: "A request, or {requests: [...]}. Queued in arrival order and answered one at a time \u2014 never merged into an allow-all.",
553
+ multiple: true
554
+ },
555
+ {
556
+ id: "elicitations",
557
+ name: "Elicitations",
558
+ type: "object",
559
+ schema: ELICITATION_SCHEMA,
560
+ description: "A question, or {requests: [...]}. `ask` selects the kind: permission | text | path | choice. Shares ONE ordered queue with `requests` \u2014 a question and a grant block the same user, so they cannot be two queues.",
561
+ multiple: true
562
+ },
563
+ {
564
+ id: "withdraw",
565
+ name: "Withdraw",
566
+ type: "string",
567
+ description: "Withdraw a request id. Resolves as deny/superseded (permission) or cancelled/superseded (question) so the awaiting host is not left hanging.",
568
+ multiple: false
569
+ }
570
+ ],
571
+ outputs: [
572
+ {
573
+ id: "decision",
574
+ name: "Decision",
575
+ type: "object",
576
+ schema: WELL_KNOWN_PORT_SCHEMAS.CONSENT_DECISION,
577
+ description: "{id, decision, scope, rememberedUntil?, reason?, grantedScopeIds?}. PERMISSIONS ONLY, in the shape this port has always carried. Exactly one per request id, ever. The host enforces; the panel grants nothing."
578
+ },
579
+ {
580
+ id: "result",
581
+ name: "Result",
582
+ type: "object",
583
+ schema: ELICITATION_RESULT_SCHEMA,
584
+ description: 'QUESTIONS ONLY. {id, ask, answered: true, reason: "user", value|optionId} when the user answered, or {id, ask, answered: false, reason} when it expired, was withdrawn or was dismissed \u2014 a cancellation carries NO value field at all. Partitioned from `decision`: a permission never appears here, so wiring both ports can never apply one grant twice.'
585
+ }
586
+ ],
587
+ commands: [
588
+ {
589
+ id: "get_pending",
590
+ title: "Get Pending",
591
+ description: "Return the queued requests, in order.",
592
+ parameters: {}
593
+ },
594
+ {
595
+ id: "deny_request",
596
+ title: "Deny Request",
597
+ description: 'Deny the current request. Permissions only \u2014 a question is refused with cancel_request, because "deny" and "no answer" are different outcomes to the host.',
598
+ parameters: {}
599
+ },
600
+ {
601
+ id: "cancel_request",
602
+ title: "Cancel Request",
603
+ description: "Settle the current request without answering it: a permission is denied, a question is cancelled. There is deliberately no command that ANSWERS one \u2014 see the note beside deny_request in panel.ts.",
604
+ parameters: {}
605
+ },
606
+ {
607
+ id: "set_duration",
608
+ title: "Set Duration",
609
+ description: "Choose once | session | always. Refused when the request forbids remembering.",
610
+ parameters: { duration: { type: "string", description: "once|session|always", required: true } }
611
+ },
612
+ {
613
+ id: "expire_due",
614
+ title: "Expire Due",
615
+ description: "Deny every request past its deadline. Expiry always denies.",
616
+ parameters: {}
617
+ }
618
+ ],
619
+ config: [
620
+ {
621
+ key: "perScope",
622
+ label: "Per-scope choices",
623
+ type: "boolean",
624
+ defaultValue: true,
625
+ description: "Let the user grant part of a request rather than all of it."
626
+ },
627
+ {
628
+ key: "defaultDuration",
629
+ label: "Default duration",
630
+ type: "select",
631
+ defaultValue: "once",
632
+ options: [
633
+ { label: "Once", value: "once" },
634
+ { label: "This session", value: "session" },
635
+ { label: "Always", value: "always" }
636
+ ],
637
+ description: "Pre-selected duration. Defaults to `once` deliberately \u2014 the safe option must be the default one."
638
+ },
639
+ {
640
+ key: "showQueueCount",
641
+ label: "Show queue count",
642
+ type: "boolean",
643
+ defaultValue: true,
644
+ description: "Show how many other requests are waiting."
645
+ },
646
+ {
647
+ key: "emptyHint",
648
+ label: "Empty hint",
649
+ type: "text",
650
+ description: "Shown when nothing is pending.",
651
+ placeholder: "Nothing is waiting for your approval."
652
+ }
653
+ ],
654
+ capabilities: ["storage.local"],
655
+ sdk: "^1.1.0"
656
+ };
657
+
658
+ // src/trust/consent/react/ConsentPanelView.tsx
659
+ import { useSyncExternalStore } from "react";
660
+ import {
661
+ Badge,
662
+ EmptyState,
663
+ Row,
664
+ RowList,
665
+ ScrollArea,
666
+ SegmentedControl,
667
+ StatusBar,
668
+ TextButton,
669
+ Toggle,
670
+ Toolbar,
671
+ ToolbarGroup
672
+ } from "@xenosystem/workbench/primitives/react";
673
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
674
+ var RISK_TONE = {
675
+ low: "neutral",
676
+ medium: "warning",
677
+ high: "error"
678
+ };
679
+ var DURATION_LABEL = { once: "Once", session: "Session", always: "Always" };
680
+ var FIELD_STYLE = {
681
+ width: "100%",
682
+ fontSize: 11,
683
+ background: "var(--xeno-canvas)",
684
+ border: "1px solid var(--xeno-border-subtle)",
685
+ borderRadius: 2,
686
+ padding: 4,
687
+ color: "inherit",
688
+ // 🔴 Text entry never takes the global focus ring: `outline-offset` draws it OUTSIDE the field,
689
+ // so on dark chrome it reads as a stray stroke over the container and the control looks broken.
690
+ outline: "none",
691
+ resize: "none"
692
+ };
693
+ var SUBMIT_LABEL = {
694
+ text: "Submit",
695
+ path: "Use this path",
696
+ choice: "Confirm"
697
+ };
698
+ function ConsentPanelView({
699
+ controller,
700
+ showQueueCount = true,
701
+ emptyHint
702
+ }) {
703
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
704
+ const request = state.current;
705
+ if (!request) {
706
+ return /* @__PURE__ */ jsx(
707
+ EmptyState,
708
+ {
709
+ title: "Nothing pending",
710
+ hint: emptyHint ?? "Requests needing your approval appear here."
711
+ }
712
+ );
713
+ }
714
+ const risk = effectiveRisk(request);
715
+ const ask = elicitationAsk(request);
716
+ const permission = isPermissionElicitation(request) ? request : null;
717
+ return /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
718
+ /* @__PURE__ */ jsx(
719
+ Toolbar,
720
+ {
721
+ left: /* @__PURE__ */ jsxs(Fragment, { children: [
722
+ /* @__PURE__ */ jsx(Badge, { tone: RISK_TONE[risk], children: `${risk} risk` }),
723
+ /* @__PURE__ */ jsx(Badge, { children: permission ? permission.kind : ask }),
724
+ request.subject ? /* @__PURE__ */ jsx("span", { style: { fontSize: 11 }, children: request.subject }) : null
725
+ ] }),
726
+ right: showQueueCount && state.queued.length > 0 ? /* @__PURE__ */ jsx(ToolbarGroup, { end: true, children: /* @__PURE__ */ jsx(Badge, { title: "Answered one at a time \u2014 never in bulk.", children: `+${state.queued.length} waiting` }) }) : void 0
727
+ }
728
+ ),
729
+ /* @__PURE__ */ jsxs(ScrollArea, { children: [
730
+ /* @__PURE__ */ jsxs("div", { style: { padding: 8, display: "flex", flexDirection: "column", gap: 6 }, children: [
731
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 12, fontWeight: 600 }, children: request.title }),
732
+ request.detail ? (
733
+ // The HOST's sentence, verbatim. The panel never composes consent prose.
734
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 11, opacity: 0.8, lineHeight: 1.45 }, children: request.detail })
735
+ ) : null,
736
+ request.fields ? Object.entries(request.fields).map(([key, value]) => /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, fontSize: 10 }, children: [
737
+ /* @__PURE__ */ jsx("span", { style: { opacity: 0.55, minWidth: 72 }, children: key }),
738
+ /* @__PURE__ */ jsx("code", { style: { userSelect: "all", wordBreak: "break-all" }, children: value })
739
+ ] }, key)) : null
740
+ ] }),
741
+ permission ? /* @__PURE__ */ jsx(RowList, { children: permission.scopes.map((scope) => /* @__PURE__ */ jsx(
742
+ Row,
743
+ {
744
+ noIcon: true,
745
+ label: /* @__PURE__ */ jsxs("span", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [
746
+ /* @__PURE__ */ jsx("span", { children: scope.label }),
747
+ scope.detail ? /* @__PURE__ */ jsx("span", { style: { fontSize: 10, opacity: 0.6 }, children: scope.detail }) : null
748
+ ] }),
749
+ meta: scope.risk && scope.risk !== risk ? /* @__PURE__ */ jsx(Badge, { tone: RISK_TONE[scope.risk], children: scope.risk }) : null,
750
+ trailing: scope.alreadyGranted ? (
751
+ // Shown for context, not as a choice — hiding it would make the new ask look
752
+ // bigger than it is.
753
+ /* @__PURE__ */ jsx(Badge, { title: "Already granted", children: "granted" })
754
+ ) : /* @__PURE__ */ jsx(
755
+ Toggle,
756
+ {
757
+ size: "sm",
758
+ label: `Grant ${scope.label}`,
759
+ checked: state.selectedScopeIds.includes(scope.id),
760
+ onChange: (next) => controller.toggleScope(scope.id, next)
761
+ }
762
+ )
763
+ },
764
+ scope.id
765
+ )) }) : /* @__PURE__ */ jsx(QuestionForm, { controller, request, state, risk })
766
+ ] }),
767
+ permission ? /* @__PURE__ */ jsxs("div", { style: { padding: "4px 8px", display: "flex", alignItems: "center", gap: 8 }, children: [
768
+ /* @__PURE__ */ jsx(
769
+ SegmentedControl,
770
+ {
771
+ size: "sm",
772
+ label: "How long",
773
+ options: allowedDurations(permission).map((d) => ({
774
+ value: d,
775
+ label: DURATION_LABEL[d]
776
+ })),
777
+ value: state.duration,
778
+ onChange: (d) => controller.setDuration(d)
779
+ }
780
+ ),
781
+ permission.remember === "forbidden" ? /* @__PURE__ */ jsx("span", { style: { fontSize: 9, opacity: 0.6 }, children: "This grant cannot be remembered." }) : null
782
+ ] }) : null,
783
+ /* @__PURE__ */ jsx(
784
+ Toolbar,
785
+ {
786
+ divided: false,
787
+ right: /* @__PURE__ */ jsx(ToolbarGroup, { end: true, children: permission ? /* @__PURE__ */ jsxs(Fragment, { children: [
788
+ /* @__PURE__ */ jsx(TextButton, { onClick: () => controller.deny(), children: "Deny" }),
789
+ /* @__PURE__ */ jsx(
790
+ TextButton,
791
+ {
792
+ strong: true,
793
+ disabled: permission.scopes.some((s) => !s.alreadyGranted) && state.selectedScopeIds.length === 0,
794
+ onClick: () => controller.allow(),
795
+ children: "Allow"
796
+ }
797
+ )
798
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
799
+ /* @__PURE__ */ jsx(TextButton, { onClick: () => controller.dismiss(), children: "Cancel" }),
800
+ /* @__PURE__ */ jsx(
801
+ TextButton,
802
+ {
803
+ strong: true,
804
+ disabled: !state.canSubmit,
805
+ onClick: () => controller.submit(),
806
+ children: SUBMIT_LABEL[ask] ?? "Submit"
807
+ }
808
+ )
809
+ ] }) })
810
+ }
811
+ ),
812
+ /* @__PURE__ */ jsx(
813
+ StatusBar,
814
+ {
815
+ left: `${state.pending} pending`,
816
+ right: state.expiresInMs !== null ? `expires in ${Math.ceil(state.expiresInMs / 1e3)}s \u2014 ${permission ? "denies" : "cancels"}` : void 0
817
+ }
818
+ )
819
+ ] });
820
+ }
821
+ function QuestionForm({ controller, request, state, risk }) {
822
+ const ask = elicitationAsk(request);
823
+ const options = elicitationOptions(request);
824
+ const max = maxTextLength(request);
825
+ const chosen = options.find((o) => o.id === state.selectedOptionId);
826
+ const multiline = ask === "text" && request.multiline === true;
827
+ const placeholder = request.placeholder ?? (ask === "path" ? "Type or paste a path\u2026" : "Type your answer\u2026");
828
+ const wantsText = ask !== "choice" || chosen?.requiresText === true;
829
+ const textLabel = request.textLabel;
830
+ return /* @__PURE__ */ jsxs("div", { style: { padding: "0 8px 8px", display: "flex", flexDirection: "column", gap: 6 }, children: [
831
+ options.length > 0 ? /* @__PURE__ */ jsx(RowList, { children: options.map((option) => /* @__PURE__ */ jsx(
832
+ Row,
833
+ {
834
+ noIcon: true,
835
+ label: /* @__PURE__ */ jsxs("span", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [
836
+ /* @__PURE__ */ jsx("span", { children: option.label }),
837
+ option.detail ? /* @__PURE__ */ jsx("span", { style: { fontSize: 10, opacity: 0.6 }, children: option.detail }) : null
838
+ ] }),
839
+ meta: option.risk && option.risk !== risk ? /* @__PURE__ */ jsx(Badge, { tone: RISK_TONE[option.risk], children: option.risk }) : null,
840
+ trailing: /* @__PURE__ */ jsx(
841
+ Toggle,
842
+ {
843
+ size: "sm",
844
+ label: `Choose ${option.label}`,
845
+ checked: state.selectedOptionId === option.id,
846
+ onChange: (next) => controller.selectOption(next ? option.id : null)
847
+ }
848
+ )
849
+ },
850
+ option.id
851
+ )) }) : null,
852
+ wantsText ? /* @__PURE__ */ jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 3 }, children: [
853
+ textLabel ? /* @__PURE__ */ jsx("span", { style: { fontSize: 10, opacity: 0.6 }, children: textLabel }) : null,
854
+ multiline ? /* @__PURE__ */ jsx(
855
+ "textarea",
856
+ {
857
+ value: state.draftText,
858
+ onChange: (event) => controller.setDraftText(event.target.value),
859
+ placeholder,
860
+ rows: 3,
861
+ spellCheck: false,
862
+ style: FIELD_STYLE
863
+ }
864
+ ) : /* @__PURE__ */ jsx(
865
+ "input",
866
+ {
867
+ type: "text",
868
+ value: state.draftText,
869
+ onChange: (event) => controller.setDraftText(event.target.value),
870
+ placeholder,
871
+ spellCheck: false,
872
+ autoComplete: "off",
873
+ style: FIELD_STYLE
874
+ }
875
+ ),
876
+ max !== null ? /* @__PURE__ */ jsx(
877
+ "span",
878
+ {
879
+ style: {
880
+ fontSize: 9,
881
+ opacity: 0.6,
882
+ alignSelf: "flex-end",
883
+ // Over the limit the answer is REFUSED, not truncated — so the counter has to say
884
+ // so rather than implying the extra characters are merely ignored.
885
+ color: state.draftText.length > max ? "var(--xeno-danger)" : void 0
886
+ },
887
+ children: `${state.draftText.length} / ${max}`
888
+ }
889
+ ) : null
890
+ ] }) : null
891
+ ] });
892
+ }
893
+
894
+ // src/trust/consent/panel.ts
895
+ function createConsentPanel(options = {}) {
896
+ return {
897
+ manifest: consentManifest,
898
+ activate(host) {
899
+ const config = host.config ?? {};
900
+ const controller = new ConsentController({
901
+ host: { emit: (portId, value) => host.emit(portId, value) },
902
+ perScope: config.perScope !== false,
903
+ defaultDuration: config.defaultDuration ?? "once"
904
+ });
905
+ const resolve = (config2) => ({
906
+ showQueueCount: config2.showQueueCount !== false,
907
+ emptyHint: typeof config2.emptyHint === "string" ? config2.emptyHint : void 0
908
+ });
909
+ let renderConfig = resolve(host.config ?? {});
910
+ let unrender = null;
911
+ let root = null;
912
+ let currentEl = null;
913
+ const draw = (el) => {
914
+ unrender?.();
915
+ if (options.render) {
916
+ unrender = options.render(el, { controller, config: renderConfig });
917
+ return;
918
+ }
919
+ root = createRoot(el);
920
+ root.render(createElement(ConsentPanelView, { controller, ...renderConfig }));
921
+ unrender = () => {
922
+ root?.unmount();
923
+ root = null;
924
+ };
925
+ };
926
+ const unbindConfig = bindConfig(host, (config2) => {
927
+ renderConfig = resolve(config2);
928
+ if (currentEl) draw(currentEl);
929
+ });
930
+ return {
931
+ render(el) {
932
+ currentEl = el;
933
+ draw(el);
934
+ },
935
+ onInput(portId, value) {
936
+ const accept = portId === "requests" ? isConsentRequest : isElicitation;
937
+ if (portId === "requests" || portId === "elicitations") {
938
+ const many = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.requests) ? value.requests : null;
939
+ if (many) controller.enqueueMany(many.filter(accept));
940
+ else if (accept(value)) controller.enqueue(value);
941
+ } else if (portId === "withdraw" && typeof value === "string") {
942
+ controller.withdraw(value);
943
+ }
944
+ },
945
+ async onCommand(commandId, params) {
946
+ switch (commandId) {
947
+ case "get_pending":
948
+ return controller.pending();
949
+ case "deny_request":
950
+ return controller.deny();
951
+ case "cancel_request":
952
+ return controller.dismiss();
953
+ case "set_duration":
954
+ return controller.setDuration(params.duration);
955
+ case "expire_due":
956
+ return controller.expireDue();
957
+ default:
958
+ return;
959
+ }
960
+ },
961
+ serialize() {
962
+ return controller.serialize();
963
+ },
964
+ deserialize() {
965
+ controller.deserialize();
966
+ },
967
+ dispose() {
968
+ unbindConfig();
969
+ currentEl = null;
970
+ unrender?.();
971
+ unrender = null;
972
+ controller.dispose();
973
+ }
974
+ };
975
+ }
976
+ };
977
+ }
978
+ var consentPanel = createConsentPanel();
979
+ export {
980
+ CONSENT_PANEL_ID,
981
+ ConsentController,
982
+ ConsentPanelView,
983
+ ELICITATION_RESULT_SCHEMA,
984
+ ELICITATION_SCHEMA,
985
+ allowedDurations,
986
+ autoCancel,
987
+ autoDeny,
988
+ consentManifest,
989
+ consentPanel,
990
+ createConsentPanel,
991
+ effectiveRisk,
992
+ elicitationAsk,
993
+ elicitationOptions,
994
+ findOption,
995
+ isConsentRequest,
996
+ isElicitation,
997
+ isElicitationAnswer,
998
+ isElicitationCancelled,
999
+ isExpired,
1000
+ isPermissionElicitation,
1001
+ maxTextLength,
1002
+ selectableScopes
1003
+ };