PolicyView.svelte raw
1 <script>
2 export let isLoggedIn = false;
3 export let userRole = "";
4 export let isPolicyAdmin = false;
5 export let policyEnabled = false;
6 export let policyJson = "";
7 export let isLoadingPolicy = false;
8 export let policyMessage = "";
9 export let policyMessageType = "";
10 export let validationErrors = [];
11 export let policyAdmins = [];
12 export let policyFollows = [];
13
14 import { createEventDispatcher } from "svelte";
15 const dispatch = createEventDispatcher();
16
17 // New admin input
18 let newAdminInput = "";
19
20 function loadPolicy() {
21 dispatch("loadPolicy");
22 }
23
24 function validatePolicy() {
25 dispatch("validatePolicy");
26 }
27
28 function savePolicy() {
29 dispatch("savePolicy");
30 }
31
32 function formatJson() {
33 dispatch("formatJson");
34 }
35
36 function openLoginModal() {
37 dispatch("openLoginModal");
38 }
39
40 function refreshFollows() {
41 dispatch("refreshFollows");
42 }
43
44 function addPolicyAdmin() {
45 if (newAdminInput.trim()) {
46 dispatch("addPolicyAdmin", newAdminInput.trim());
47 newAdminInput = "";
48 }
49 }
50
51 function removePolicyAdmin(pubkey) {
52 dispatch("removePolicyAdmin", pubkey);
53 }
54
55 // Parse admins from current policy JSON for display
56 $: {
57 try {
58 if (policyJson) {
59 const parsed = JSON.parse(policyJson);
60 policyAdmins = parsed.policy_admins || [];
61 }
62 } catch (e) {
63 // Ignore parse errors
64 }
65 }
66
67 // Pretty-print example policy for reference
68 const examplePolicy = `{
69 "kind": {
70 "whitelist": [0, 1, 3, 6, 7, 10002],
71 "blacklist": []
72 },
73 "global": {
74 "description": "Global rules applied to all events",
75 "size_limit": 65536,
76 "max_age_of_event": 86400,
77 "max_age_event_in_future": 300
78 },
79 "rules": {
80 "1": {
81 "description": "Kind 1 (short text notes)",
82 "content_limit": 8192,
83 "write_allow_follows": true
84 },
85 "30023": {
86 "description": "Long-form articles",
87 "content_limit": 100000,
88 "tag_validation": {
89 "d": "^[a-z0-9-]{1,64}$",
90 "t": "^[a-z0-9-]{1,32}$"
91 }
92 }
93 },
94 "default_policy": "allow",
95 "policy_admins": ["<your-hex-pubkey>"],
96 "policy_follow_whitelist_enabled": true
97 }`;
98 </script>
99
100 <div class="policy-view">
101 <h2>Policy Configuration</h2>
102 {#if isLoggedIn && (userRole === "owner" || isPolicyAdmin)}
103 <div class="policy-section">
104 <div class="policy-header">
105 <h3>Policy Editor</h3>
106 <div class="policy-status">
107 <span class="status-badge" class:enabled={policyEnabled}>
108 {policyEnabled ? "Policy Enabled" : "Policy Disabled"}
109 </span>
110 {#if isPolicyAdmin}
111 <span class="admin-badge">Policy Admin</span>
112 {/if}
113 </div>
114 </div>
115
116 <div class="policy-info">
117 <p>
118 Edit the policy JSON below and click "Save & Publish" to update the relay's policy configuration.
119 Changes are applied immediately after validation.
120 </p>
121 <p class="info-note">
122 Policy updates are published as kind 12345 events and require policy admin permissions.
123 </p>
124 </div>
125
126 <div class="editor-container">
127 <textarea
128 class="policy-editor"
129 bind:value={policyJson}
130 placeholder="Loading policy configuration..."
131 disabled={isLoadingPolicy}
132 spellcheck="false"
133 ></textarea>
134 </div>
135
136 {#if validationErrors.length > 0}
137 <div class="validation-errors">
138 <h4>Validation Errors:</h4>
139 <ul>
140 {#each validationErrors as error}
141 <li>{error}</li>
142 {/each}
143 </ul>
144 </div>
145 {/if}
146
147 <div class="policy-actions">
148 <button
149 class="policy-btn load-btn"
150 on:click={loadPolicy}
151 disabled={isLoadingPolicy}
152 >
153 Load Current
154 </button>
155 <button
156 class="policy-btn format-btn"
157 on:click={formatJson}
158 disabled={isLoadingPolicy}
159 >
160 Format JSON
161 </button>
162 <button
163 class="policy-btn validate-btn"
164 on:click={validatePolicy}
165 disabled={isLoadingPolicy}
166 >
167 Validate
168 </button>
169 <button
170 class="policy-btn save-btn"
171 on:click={savePolicy}
172 disabled={isLoadingPolicy}
173 >
174 Save & Publish
175 </button>
176 </div>
177
178 {#if policyMessage}
179 <div
180 class="policy-message"
181 class:error={policyMessageType === "error"}
182 class:success={policyMessageType === "success"}
183 >
184 {policyMessage}
185 </div>
186 {/if}
187 </div>
188
189 <!-- Policy Admins Section -->
190 <div class="policy-section">
191 <h3>Policy Administrators</h3>
192 <div class="policy-info">
193 <p>
194 Policy admins can update the relay's policy configuration via kind 12345 events.
195 Their follows get whitelisted if <code>policy_follow_whitelist_enabled</code> is true in the policy.
196 </p>
197 <p class="info-note">
198 <strong>Note:</strong> Policy admins are separate from relay admins (ORLY_ADMINS).
199 Changes here update the JSON editor - click "Save & Publish" to apply.
200 </p>
201 </div>
202
203 <div class="admin-list">
204 {#if policyAdmins.length === 0}
205 <p class="no-items">No policy admins configured</p>
206 {:else}
207 {#each policyAdmins as admin}
208 <div class="admin-item">
209 <span class="admin-pubkey" title={admin}>{admin.substring(0, 16)}...{admin.substring(admin.length - 8)}</span>
210 <button
211 class="remove-btn"
212 on:click={() => removePolicyAdmin(admin)}
213 disabled={isLoadingPolicy}
214 title="Remove admin"
215 >
216 ✕
217 </button>
218 </div>
219 {/each}
220 {/if}
221 </div>
222
223 <div class="add-admin">
224 <input
225 type="text"
226 placeholder="npub or hex pubkey"
227 bind:value={newAdminInput}
228 disabled={isLoadingPolicy}
229 on:keydown={(e) => e.key === "Enter" && addPolicyAdmin()}
230 />
231 <button
232 class="policy-btn add-btn"
233 on:click={addPolicyAdmin}
234 disabled={isLoadingPolicy || !newAdminInput.trim()}
235 >
236 + Add Admin
237 </button>
238 </div>
239 </div>
240
241 <!-- Policy Follow Whitelist Section -->
242 <div class="policy-section">
243 <h3>Policy Follow Whitelist</h3>
244 <div class="policy-info">
245 <p>
246 Pubkeys followed by policy admins (kind 3 events).
247 These get automatic read+write access when rules have <code>write_allow_follows: true</code>.
248 </p>
249 </div>
250
251 <div class="follows-header">
252 <span class="follows-count">{policyFollows.length} pubkey(s) in whitelist</span>
253 <button
254 class="policy-btn refresh-btn"
255 on:click={refreshFollows}
256 disabled={isLoadingPolicy}
257 >
258 🔄 Refresh Follows
259 </button>
260 </div>
261
262 <div class="follows-list">
263 {#if policyFollows.length === 0}
264 <p class="no-items">No follows loaded. Click "Refresh Follows" to load from database.</p>
265 {:else}
266 <div class="follows-grid">
267 {#each policyFollows as follow}
268 <div class="follow-item" title={follow}>
269 {follow.substring(0, 12)}...{follow.substring(follow.length - 6)}
270 </div>
271 {/each}
272 </div>
273 {/if}
274 </div>
275 </div>
276
277 <div class="policy-section">
278 <h3>Policy Reference</h3>
279 <div class="reference-content">
280 <h4>Structure Overview</h4>
281 <ul class="field-list">
282 <li><code>kind.whitelist</code> - Only allow these event kinds (takes precedence)</li>
283 <li><code>kind.blacklist</code> - Deny these event kinds (if no whitelist)</li>
284 <li><code>global</code> - Rules applied to all events</li>
285 <li><code>rules</code> - Per-kind rules (keyed by kind number as string)</li>
286 <li><code>default_policy</code> - "allow" or "deny" when no rules match</li>
287 <li><code>policy_admins</code> - Hex pubkeys that can update policy</li>
288 <li><code>policy_follow_whitelist_enabled</code> - Enable follow-based access</li>
289 </ul>
290
291 <h4>Rule Fields</h4>
292 <ul class="field-list">
293 <li><code>description</code> - Human-readable rule description</li>
294 <li><code>write_allow</code> / <code>write_deny</code> - Pubkey lists for write access</li>
295 <li><code>read_allow</code> / <code>read_deny</code> - Pubkey lists for read access</li>
296 <li><code>write_allow_follows</code> - Grant access to policy admin follows</li>
297 <li><code>size_limit</code> - Max total event size in bytes</li>
298 <li><code>content_limit</code> - Max content field size in bytes</li>
299 <li><code>max_expiry</code> - Max expiry offset in seconds</li>
300 <li><code>max_age_of_event</code> - Max age of created_at in seconds</li>
301 <li><code>max_age_event_in_future</code> - Max future offset in seconds</li>
302 <li><code>must_have_tags</code> - Required tag letters (e.g., ["d", "t"])</li>
303 <li><code>tag_validation</code> - Regex patterns for tag values</li>
304 <li><code>script</code> - Path to external validation script</li>
305 </ul>
306
307 <h4>Example Policy</h4>
308 <pre class="example-json">{examplePolicy}</pre>
309 </div>
310 </div>
311 {:else if isLoggedIn}
312 <div class="permission-denied">
313 <p>Policy configuration requires owner or policy admin permissions.</p>
314 <p>
315 To become a policy admin, ask an existing policy admin to add your pubkey
316 to the <code>policy_admins</code> list.
317 </p>
318 <p>
319 Current user role: <strong>{userRole || "none"}</strong>
320 </p>
321 </div>
322 {:else}
323 <div class="login-prompt">
324 <p>Please log in to access policy configuration.</p>
325 <button class="login-btn" on:click={openLoginModal}>Log In</button>
326 </div>
327 {/if}
328 </div>
329
330 <style>
331 .policy-view {
332 width: 100%;
333 max-width: 1200px;
334 margin: 0;
335 padding: 20px;
336 background: var(--header-bg);
337 color: var(--text-color);
338 border-radius: 8px;
339 box-sizing: border-box;
340 }
341
342 .policy-view h2 {
343 margin: 0 0 1.5rem 0;
344 color: var(--text-color);
345 font-size: 1.8rem;
346 font-weight: 600;
347 }
348
349 .policy-section {
350 background-color: var(--card-bg);
351 border-radius: 8px;
352 padding: 1.5em;
353 margin-bottom: 1.5rem;
354 border: 1px solid var(--border-color);
355 }
356
357 .policy-header {
358 display: flex;
359 justify-content: space-between;
360 align-items: center;
361 margin-bottom: 1rem;
362 }
363
364 .policy-header h3 {
365 margin: 0;
366 color: var(--text-color);
367 font-size: 1.2rem;
368 font-weight: 600;
369 }
370
371 .policy-status {
372 display: flex;
373 gap: 0.5rem;
374 }
375
376 .status-badge {
377 padding: 0.25em 0.75em;
378 border-radius: 1rem;
379 font-size: 0.8em;
380 font-weight: 600;
381 background: var(--danger);
382 color: white;
383 }
384
385 .status-badge.enabled {
386 background: var(--success);
387 }
388
389 .admin-badge {
390 padding: 0.25em 0.75em;
391 border-radius: 1rem;
392 font-size: 0.8em;
393 font-weight: 600;
394 background: var(--primary);
395 color: white;
396 }
397
398 .policy-info {
399 margin-bottom: 1rem;
400 padding: 1rem;
401 background: var(--bg-color);
402 border-radius: 4px;
403 border: 1px solid var(--border-color);
404 }
405
406 .policy-info p {
407 margin: 0 0 0.5rem 0;
408 line-height: 1.5;
409 }
410
411 .policy-info p:last-child {
412 margin-bottom: 0;
413 }
414
415 .info-note {
416 font-size: 0.9em;
417 opacity: 0.8;
418 }
419
420 .editor-container {
421 margin-bottom: 1rem;
422 }
423
424 .policy-editor {
425 width: 100%;
426 height: 400px;
427 padding: 1em;
428 border: 1px solid var(--border-color);
429 border-radius: 4px;
430 background: var(--input-bg);
431 color: var(--input-text-color);
432 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
433 font-size: 0.85em;
434 line-height: 1.5;
435 resize: vertical;
436 tab-size: 2;
437 }
438
439 .policy-editor:disabled {
440 opacity: 0.6;
441 cursor: not-allowed;
442 }
443
444 .validation-errors {
445 margin-bottom: 1rem;
446 padding: 1rem;
447 background: var(--danger-bg, rgba(220, 53, 69, 0.1));
448 border: 1px solid var(--danger);
449 border-radius: 4px;
450 }
451
452 .validation-errors h4 {
453 margin: 0 0 0.5rem 0;
454 color: var(--danger);
455 font-size: 1rem;
456 }
457
458 .validation-errors ul {
459 margin: 0;
460 padding-left: 1.5rem;
461 }
462
463 .validation-errors li {
464 color: var(--danger);
465 margin-bottom: 0.25rem;
466 }
467
468 .policy-actions {
469 display: flex;
470 gap: 0.5rem;
471 flex-wrap: wrap;
472 }
473
474 .policy-btn {
475 background: var(--primary);
476 color: white;
477 border: none;
478 padding: 0.5em 1em;
479 border-radius: 4px;
480 cursor: pointer;
481 font-size: 0.9em;
482 transition: background-color 0.2s, filter 0.2s;
483 display: flex;
484 align-items: center;
485 gap: 0.25em;
486 }
487
488 .policy-btn:hover:not(:disabled) {
489 filter: brightness(1.1);
490 }
491
492 .policy-btn:disabled {
493 background: var(--secondary);
494 cursor: not-allowed;
495 }
496
497 .load-btn {
498 background: var(--info);
499 }
500
501 .format-btn {
502 background: var(--secondary);
503 }
504
505 .validate-btn {
506 background: var(--warning);
507 }
508
509 .save-btn {
510 background: var(--success);
511 }
512
513 .policy-message {
514 padding: 1rem;
515 border-radius: 4px;
516 margin-top: 1rem;
517 background: var(--info-bg, rgba(23, 162, 184, 0.1));
518 color: var(--info-text, var(--text-color));
519 border: 1px solid var(--info);
520 }
521
522 .policy-message.error {
523 background: var(--danger-bg, rgba(220, 53, 69, 0.1));
524 color: var(--danger-text, var(--danger));
525 border: 1px solid var(--danger);
526 }
527
528 .policy-message.success {
529 background: var(--success-bg, rgba(40, 167, 69, 0.1));
530 color: var(--success-text, var(--success));
531 border: 1px solid var(--success);
532 }
533
534 .reference-content h4 {
535 margin: 1rem 0 0.5rem 0;
536 color: var(--text-color);
537 font-size: 1rem;
538 }
539
540 .reference-content h4:first-child {
541 margin-top: 0;
542 }
543
544 .field-list {
545 margin: 0 0 1rem 0;
546 padding-left: 1.5rem;
547 }
548
549 .field-list li {
550 margin-bottom: 0.25rem;
551 line-height: 1.5;
552 }
553
554 .field-list code {
555 background: var(--code-bg, rgba(0, 0, 0, 0.1));
556 padding: 0.1em 0.4em;
557 border-radius: 3px;
558 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
559 font-size: 0.9em;
560 }
561
562 .example-json {
563 background: var(--input-bg);
564 color: var(--input-text-color);
565 padding: 1rem;
566 border-radius: 4px;
567 border: 1px solid var(--border-color);
568 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
569 font-size: 0.8em;
570 line-height: 1.4;
571 overflow-x: auto;
572 white-space: pre;
573 margin: 0;
574 }
575
576 .permission-denied,
577 .login-prompt {
578 text-align: center;
579 padding: 2em;
580 background-color: var(--card-bg);
581 border-radius: 8px;
582 border: 1px solid var(--border-color);
583 color: var(--text-color);
584 }
585
586 .permission-denied p,
587 .login-prompt p {
588 margin: 0 0 1rem 0;
589 line-height: 1.4;
590 }
591
592 .permission-denied code {
593 background: var(--code-bg, rgba(0, 0, 0, 0.1));
594 padding: 0.2em 0.4em;
595 border-radius: 0.25rem;
596 font-family: monospace;
597 font-size: 0.9em;
598 }
599
600 .login-btn {
601 background: var(--primary);
602 color: white;
603 border: none;
604 padding: 0.75em 1.5em;
605 border-radius: 4px;
606 cursor: pointer;
607 font-weight: bold;
608 font-size: 0.9em;
609 transition: background-color 0.2s;
610 }
611
612 .login-btn:hover {
613 filter: brightness(1.1);
614 }
615
616 /* Admin list styles */
617 .admin-list {
618 margin-bottom: 1rem;
619 }
620
621 .admin-item {
622 display: flex;
623 justify-content: space-between;
624 align-items: center;
625 padding: 0.5em 0.75em;
626 background: var(--bg-color);
627 border: 1px solid var(--border-color);
628 border-radius: 4px;
629 margin-bottom: 0.5rem;
630 }
631
632 .admin-pubkey {
633 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
634 font-size: 0.85em;
635 color: var(--text-color);
636 }
637
638 .remove-btn {
639 background: var(--danger);
640 color: white;
641 border: none;
642 width: 24px;
643 height: 24px;
644 border-radius: 50%;
645 cursor: pointer;
646 font-size: 0.8em;
647 display: flex;
648 align-items: center;
649 justify-content: center;
650 transition: filter 0.2s;
651 }
652
653 .remove-btn:hover:not(:disabled) {
654 filter: brightness(0.9);
655 }
656
657 .remove-btn:disabled {
658 opacity: 0.5;
659 cursor: not-allowed;
660 }
661
662 .add-admin {
663 display: flex;
664 gap: 0.5rem;
665 }
666
667 .add-admin input {
668 flex: 1;
669 padding: 0.5em 0.75em;
670 border: 1px solid var(--border-color);
671 border-radius: 4px;
672 background: var(--input-bg);
673 color: var(--input-text-color);
674 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
675 font-size: 0.85em;
676 }
677
678 .add-btn {
679 background: var(--success);
680 white-space: nowrap;
681 }
682
683 .no-items {
684 color: var(--text-color);
685 opacity: 0.6;
686 font-style: italic;
687 padding: 1rem;
688 text-align: center;
689 }
690
691 /* Follow list styles */
692 .follows-header {
693 display: flex;
694 justify-content: space-between;
695 align-items: center;
696 margin-bottom: 1rem;
697 }
698
699 .follows-count {
700 font-weight: 600;
701 color: var(--text-color);
702 }
703
704 .refresh-btn {
705 background: var(--info);
706 }
707
708 .follows-list {
709 max-height: 300px;
710 overflow-y: auto;
711 border: 1px solid var(--border-color);
712 border-radius: 4px;
713 background: var(--bg-color);
714 }
715
716 .follows-grid {
717 display: grid;
718 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
719 gap: 0.5rem;
720 padding: 0.75rem;
721 }
722
723 .follow-item {
724 padding: 0.4em 0.6em;
725 background: var(--card-bg);
726 border: 1px solid var(--border-color);
727 border-radius: 4px;
728 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
729 font-size: 0.75em;
730 color: var(--text-color);
731 text-overflow: ellipsis;
732 overflow: hidden;
733 white-space: nowrap;
734 }
735 </style>
736