-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin-development.html
More file actions
659 lines (657 loc) · 388 KB
/
Copy pathplugin-development.html
File metadata and controls
659 lines (657 loc) · 388 KB
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
<!DOCTYPE html>
<html lang="en-US" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Plugin development | Sovereign</title>
<meta name="description" content="Sovereign is an open-source workspace runtime for hosting private, multi-user apps on infrastructure you control.">
<meta name="generator" content="VitePress v1.6.4">
<link rel="preload stylesheet" href="/assets/style.zwcujjeL.css" as="style">
<link rel="preload stylesheet" href="/vp-icons.css" as="style">
<script type="module" src="/assets/app.6EG36zm-.js"></script>
<link rel="preload" href="/assets/inter-roman-latin.Di8DUHzh.woff2" as="font" type="font/woff2" crossorigin="">
<link rel="modulepreload" href="/assets/chunks/theme.uZs-Jr1G.js">
<link rel="modulepreload" href="/assets/chunks/framework.CHeM0PsO.js">
<link rel="modulepreload" href="/assets/plugin-development.md.CwXd-zw0.lean.js">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<script id="check-dark-mode">(()=>{const e=localStorage.getItem("vitepress-theme-appearance")||"auto",a=window.matchMedia("(prefers-color-scheme: dark)").matches;(!e||e==="auto"?a:e==="dark")&&document.documentElement.classList.add("dark")})();</script>
<script id="check-mac-os">document.documentElement.classList.toggle("mac",/Mac|iPhone|iPod|iPad/i.test(navigator.platform));</script>
<link rel="canonical" href="https://sovereignfs.github.io/plugin-development">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Sovereign">
<meta property="og:title" content="Plugin development | Sovereign">
<meta property="og:description" content="Sovereign is an open-source workspace runtime for hosting private, multi-user apps on infrastructure you control.">
<meta property="og:url" content="https://sovereignfs.github.io/plugin-development">
<meta property="og:image" content="https://sovereignfs.github.io/social-preview.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Plugin development | Sovereign">
<meta name="twitter:description" content="Sovereign is an open-source workspace runtime for hosting private, multi-user apps on infrastructure you control.">
<meta name="twitter:image" content="https://sovereignfs.github.io/social-preview.png">
</head>
<body>
<div id="app"><div class="Layout" data-v-715c0237><!--[--><!--]--><!--[--><span tabindex="-1" data-v-c20d2363></span><a href="#VPContent" class="VPSkipLink visually-hidden" data-v-c20d2363>Skip to content</a><!--]--><!----><header class="VPNav" data-v-715c0237 data-v-a762df62><div class="VPNavBar" data-v-a762df62 data-v-b6b841c0><div class="wrapper" data-v-b6b841c0><div class="container" data-v-b6b841c0><div class="title" data-v-b6b841c0><div class="VPNavBarTitle has-sidebar" data-v-b6b841c0 data-v-27bf94fe><a class="title" href="/" data-v-27bf94fe><!--[--><!--]--><!----><span data-v-27bf94fe>Sovereign</span><!--[--><!--]--></a></div></div><div class="content" data-v-b6b841c0><div class="content-body" data-v-b6b841c0><!--[--><!--]--><div class="VPNavBarSearch search" data-v-b6b841c0><!--[--><!----><div id="local-search"><button type="button" class="DocSearch DocSearch-Button" aria-label="Search"><span class="DocSearch-Button-Container"><span class="vp-icon DocSearch-Search-Icon"></span><span class="DocSearch-Button-Placeholder">Search</span></span><span class="DocSearch-Button-Keys"><kbd class="DocSearch-Button-Key"></kbd><kbd class="DocSearch-Button-Key">K</kbd></span></button></div><!--]--></div><nav aria-labelledby="main-nav-aria-label" class="VPNavBarMenu menu" data-v-b6b841c0 data-v-7f9a5953><span id="main-nav-aria-label" class="visually-hidden" data-v-7f9a5953> Main Navigation </span><!--[--><!--[--><div class="VPFlyout VPNavBarMenuGroup" data-v-7f9a5953 data-v-2c06edda><button type="button" class="button" aria-haspopup="true" aria-expanded="false" data-v-2c06edda><span class="text" data-v-2c06edda><!----><span data-v-2c06edda>Product</span><span class="vpi-chevron-down text-icon" data-v-2c06edda></span></span></button><div class="menu" data-v-2c06edda><div class="VPMenu" data-v-2c06edda data-v-aa41a574><div class="items" data-v-aa41a574><!--[--><!--[--><div class="VPMenuLink" data-v-aa41a574 data-v-7e2521cb><a class="VPLink link" href="/" data-v-7e2521cb><!--[--><span data-v-7e2521cb>Sovereign</span><!--]--></a></div><!--]--><!--[--><div class="VPMenuLink" data-v-aa41a574 data-v-7e2521cb><a class="VPLink link" href="/sovereign-os/" data-v-7e2521cb><!--[--><span data-v-7e2521cb>Sovereign OS</span><!--]--></a></div><!--]--><!--[--><div class="VPMenuLink" data-v-aa41a574 data-v-7e2521cb><a class="VPLink link" href="/sovereign-edge/" data-v-7e2521cb><!--[--><span data-v-7e2521cb>Sovereign Edge</span><!--]--></a></div><!--]--><!--]--></div><!--[--><!--]--></div></div></div><!--]--><!--[--><a class="VPLink link VPNavBarMenuLink" href="/instances.html" tabindex="0" data-v-7f9a5953 data-v-63942258><!--[--><span data-v-63942258>Instances</span><!--]--></a><!--]--><!--[--><div class="VPFlyout VPNavBarMenuGroup" data-v-7f9a5953 data-v-2c06edda><button type="button" class="button" aria-haspopup="true" aria-expanded="false" data-v-2c06edda><span class="text" data-v-2c06edda><!----><span data-v-2c06edda>Docs</span><span class="vpi-chevron-down text-icon" data-v-2c06edda></span></span></button><div class="menu" data-v-2c06edda><div class="VPMenu" data-v-2c06edda data-v-aa41a574><div class="items" data-v-aa41a574><!--[--><!--[--><div class="VPMenuLink" data-v-aa41a574 data-v-7e2521cb><a class="VPLink link" href="/get-started/" data-v-7e2521cb><!--[--><span data-v-7e2521cb>Get Started</span><!--]--></a></div><!--]--><!--[--><div class="VPMenuLink" data-v-aa41a574 data-v-7e2521cb><a class="VPLink link" href="/docs/" data-v-7e2521cb><!--[--><span data-v-7e2521cb>Full Documentation</span><!--]--></a></div><!--]--><!--]--></div><!--[--><!--]--></div></div></div><!--]--><!--[--><a class="VPLink link VPNavBarMenuLink" href="/product-roadmap.html" tabindex="0" data-v-7f9a5953 data-v-63942258><!--[--><span data-v-63942258>Roadmap</span><!--]--></a><!--]--><!--[--><a class="VPLink link vp-external-link-icon VPNavBarMenuLink" href="https://github.com/sovereignfs/sovereign" target="_blank" rel="noreferrer" tabindex="0" data-v-7f9a5953 data-v-63942258><!--[--><span data-v-63942258>GitHub</span><!--]--></a><!--]--><!--]--></nav><!----><div class="VPNavBarAppearance appearance" data-v-b6b841c0 data-v-ea9437ab><button class="VPSwitch VPSwitchAppearance" type="button" role="switch" title aria-checked="false" data-v-ea9437ab data-v-d11a2ca1 data-v-545e19d1><span class="check" data-v-545e19d1><span class="icon" data-v-545e19d1><!--[--><span class="vpi-sun sun" data-v-d11a2ca1></span><span class="vpi-moon moon" data-v-d11a2ca1></span><!--]--></span></span></button></div><div class="VPSocialLinks VPNavBarSocialLinks social-links" data-v-b6b841c0 data-v-78fc8d62 data-v-b533a98f><!--[--><a class="VPSocialLink no-icon" href="https://github.com/sovereignfs/sovereign" aria-label="github" target="_blank" rel="noopener" data-v-b533a98f data-v-4522f3d9><span class="vpi-social-github"></span></a><!--]--></div><div class="VPFlyout VPNavBarExtra extra" data-v-b6b841c0 data-v-0d5ff54f data-v-2c06edda><button type="button" class="button" aria-haspopup="true" aria-expanded="false" aria-label="extra navigation" data-v-2c06edda><span class="vpi-more-horizontal icon" data-v-2c06edda></span></button><div class="menu" data-v-2c06edda><div class="VPMenu" data-v-2c06edda data-v-aa41a574><!----><!--[--><!--[--><!----><div class="group" data-v-0d5ff54f><div class="item appearance" data-v-0d5ff54f><p class="label" data-v-0d5ff54f>Appearance</p><div class="appearance-action" data-v-0d5ff54f><button class="VPSwitch VPSwitchAppearance" type="button" role="switch" title aria-checked="false" data-v-0d5ff54f data-v-d11a2ca1 data-v-545e19d1><span class="check" data-v-545e19d1><span class="icon" data-v-545e19d1><!--[--><span class="vpi-sun sun" data-v-d11a2ca1></span><span class="vpi-moon moon" data-v-d11a2ca1></span><!--]--></span></span></button></div></div></div><div class="group" data-v-0d5ff54f><div class="item social-links" data-v-0d5ff54f><div class="VPSocialLinks social-links-list" data-v-0d5ff54f data-v-b533a98f><!--[--><a class="VPSocialLink no-icon" href="https://github.com/sovereignfs/sovereign" aria-label="github" target="_blank" rel="noopener" data-v-b533a98f data-v-4522f3d9><span class="vpi-social-github"></span></a><!--]--></div></div></div><!--]--><!--]--></div></div></div><!--[--><!--]--><button type="button" class="VPNavBarHamburger hamburger" aria-label="mobile navigation" aria-expanded="false" aria-controls="VPNavScreen" data-v-b6b841c0 data-v-ce597b2c><span class="container" data-v-ce597b2c><span class="top" data-v-ce597b2c></span><span class="middle" data-v-ce597b2c></span><span class="bottom" data-v-ce597b2c></span></span></button></div></div></div></div><div class="divider" data-v-b6b841c0><div class="divider-line" data-v-b6b841c0></div></div></div><!----></header><div class="VPLocalNav has-sidebar empty" data-v-715c0237 data-v-f158f50d><div class="container" data-v-f158f50d><button class="menu" aria-expanded="false" aria-controls="VPSidebarNav" data-v-f158f50d><span class="vpi-align-left menu-icon" data-v-f158f50d></span><span class="menu-text" data-v-f158f50d>Menu</span></button><div class="VPLocalNavOutlineDropdown" style="--vp-vh:0px;" data-v-f158f50d data-v-4d8a5c57><button data-v-4d8a5c57>Return to top</button><!----></div></div></div><aside class="VPSidebar" data-v-715c0237 data-v-360d37b6><div class="curtain" data-v-360d37b6></div><nav class="nav" id="VPSidebarNav" aria-labelledby="sidebar-aria-label" tabindex="-1" data-v-360d37b6><span class="visually-hidden" id="sidebar-aria-label" data-v-360d37b6> Sidebar Navigation </span><!--[--><!--]--><!--[--><div class="no-transition group" data-v-f954693f><section class="VPSidebarItem level-0" data-v-f954693f data-v-aeb3effe><div class="item" role="button" tabindex="0" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><h2 class="text" data-v-aeb3effe>Operator Guides</h2><!----></div><div class="items" data-v-aeb3effe><!--[--><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/self-hosting.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Self-Hosting</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/upgrade.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Upgrade Guide</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/troubleshooting.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Troubleshooting</p><!--]--></a><!----></div><!----></div><!--]--></div></section></div><div class="no-transition group" data-v-f954693f><section class="VPSidebarItem level-0 has-active" data-v-f954693f data-v-aeb3effe><div class="item" role="button" tabindex="0" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><h2 class="text" data-v-aeb3effe>App Developer Guides</h2><!----></div><div class="items" data-v-aeb3effe><!--[--><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/plugin-development.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Overview</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/sdk-stability.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>SDK Stability</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/plugin-database.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Plugin Database</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/design-system.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Design System</p><!--]--></a><!----></div><!----></div><!--]--></div></section></div><div class="no-transition group" data-v-f954693f><section class="VPSidebarItem level-0" data-v-f954693f data-v-aeb3effe><div class="item" role="button" tabindex="0" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><h2 class="text" data-v-aeb3effe>Architecture & Security</h2><!----></div><div class="items" data-v-aeb3effe><!--[--><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/architecture.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Architecture</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/security.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Security</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/repositories.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Repository Map</p><!--]--></a><!----></div><!----></div><!--]--></div></section></div><div class="no-transition group" data-v-f954693f><section class="VPSidebarItem level-0" data-v-f954693f data-v-aeb3effe><div class="item" role="button" tabindex="0" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><h2 class="text" data-v-aeb3effe>Core Plugins</h2><!----></div><div class="items" data-v-aeb3effe><!--[--><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/plugins/console.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Console</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/plugins/launcher.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Launcher</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/plugins/account.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Account</p><!--]--></a><!----></div><!----></div><!--]--></div></section></div><div class="no-transition group" data-v-f954693f><section class="VPSidebarItem level-0" data-v-f954693f data-v-aeb3effe><div class="item" role="button" tabindex="0" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><h2 class="text" data-v-aeb3effe>Contributor Guides</h2><!----></div><div class="items" data-v-aeb3effe><!--[--><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/documentation-structure.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Documentation Structure</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/development-workflow.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Development Workflow</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/agent-first-documentation.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Agent-First Documentation</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/architecture-rules.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Architecture Rules</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/testing-e2e.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>Testing E2E</p><!--]--></a><!----></div><!----></div><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/pwa-real-device-testing.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>PWA Device Testing</p><!--]--></a><!----></div><!----></div><!--]--></div></section></div><div class="no-transition group" data-v-f954693f><section class="VPSidebarItem level-0 collapsible collapsed" data-v-f954693f data-v-aeb3effe><div class="item" role="button" tabindex="0" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><h2 class="text" data-v-aeb3effe>RFCs</h2><div class="caret" role="button" aria-label="toggle section" tabindex="0" data-v-aeb3effe><span class="vpi-chevron-right caret-icon" data-v-aeb3effe></span></div></div><div class="items" data-v-aeb3effe><!--[--><div class="VPSidebarItem level-1 is-link" data-v-aeb3effe><div class="item" data-v-aeb3effe><div class="indicator" data-v-aeb3effe></div><a class="VPLink link link" href="/rfcs/README.html" data-v-aeb3effe><!--[--><p class="text" data-v-aeb3effe>RFC Index</p><!--]--></a><!----></div><!----></div><!--]--></div></section></div><!--]--><!--[--><!--]--></nav></aside><div class="VPContent has-sidebar" id="VPContent" data-v-715c0237 data-v-41cd1916><div class="VPDoc has-sidebar has-aside" data-v-41cd1916 data-v-d9bf7dc2><!--[--><!--]--><div class="container" data-v-d9bf7dc2><div class="aside" data-v-d9bf7dc2><div class="aside-curtain" data-v-d9bf7dc2></div><div class="aside-container" data-v-d9bf7dc2><div class="aside-content" data-v-d9bf7dc2><div class="VPDocAside" data-v-d9bf7dc2 data-v-a7bc4d01><!--[--><!--]--><!--[--><!--]--><nav aria-labelledby="doc-outline-aria-label" class="VPDocAsideOutline" data-v-a7bc4d01 data-v-77892794><div class="content" data-v-77892794><div class="outline-marker" data-v-77892794></div><div aria-level="2" class="outline-title" id="doc-outline-aria-label" role="heading" data-v-77892794>On this page</div><ul class="VPDocOutlineItem root" data-v-77892794 data-v-9a6a84b8><!--[--><!--]--></ul></div></nav><!--[--><!--]--><div class="spacer" data-v-a7bc4d01></div><!--[--><!--]--><!----><!--[--><!--]--><!--[--><!--]--></div></div></div></div><div class="content" data-v-d9bf7dc2><div class="content-container" data-v-d9bf7dc2><!--[--><!--]--><main class="main" data-v-d9bf7dc2><div style="position:relative;" class="vp-doc _plugin-development" data-v-d9bf7dc2><div><h1 id="plugin-development" tabindex="-1">Plugin development <a class="header-anchor" href="#plugin-development" aria-label="Permalink to "Plugin development""></a></h1><p>This guide is for building a Sovereign plugin. A plugin is a self-contained app — Next.js App Router routes plus an optional database schema — that the platform composes into the runtime at build time and serves under a URL prefix you choose. Plugins talk to the platform <strong>only</strong> through the SDK (<code>@sovereignfs/sdk</code>); they never import runtime internals.</p><p>If you are deploying an instance rather than building a plugin, see <a href="./self-hosting.html">self-hosting.md</a>. For the platform internals, see <a href="./architecture.html">architecture.md</a>.</p><h2 id="getting-started" tabindex="-1">Getting started <a class="header-anchor" href="#getting-started" aria-label="Permalink to "Getting started""></a></h2><p>Three ways to scaffold a new plugin from the canonical skeleton:</p><h3 id="_1-sv-plugin-new-—-inside-a-sovereign-monorepo-checkout" tabindex="-1">1. <code>sv plugin new</code> — inside a Sovereign monorepo checkout <a class="header-anchor" href="#_1-sv-plugin-new-—-inside-a-sovereign-monorepo-checkout" aria-label="Permalink to "1. `sv plugin new` — inside a Sovereign monorepo checkout""></a></h3><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">pnpm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sv</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugin</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> new</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> io.example.my-plugin</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># Options:</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># --name <name> Display name (default: derived from ID)</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># --description <text> Short plugin description</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># --route <prefix> Route prefix, e.g. /my-plugin (default: /<last-id-segment>)</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># --out <dir> Parent directory (default: ./plugins)</span></span></code></pre></div><p>Creates <code>plugins/io.example.my-plugin/</code> with the canonical skeleton and uses <code>workspace:*</code> / <code>catalog:</code> references so the plugin is immediately runnable with <code>pnpm dev</code>.</p><h3 id="_2-npm-create-sovereignfs-plugin-—-standalone-plugin-repository" tabindex="-1">2. <code>npm create @sovereignfs/plugin</code> — standalone plugin repository <a class="header-anchor" href="#_2-npm-create-sovereignfs-plugin-—-standalone-plugin-repository" aria-label="Permalink to "2. `npm create @sovereignfs/plugin` — standalone plugin repository""></a></h3><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">npm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> create</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> @sovereignfs/plugin</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># or: pnpm create @sovereignfs/plugin</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># or: yarn create @sovereignfs/plugin</span></span></code></pre></div><p>Interactive: asks for plugin ID, display name, description, and route prefix. Creates a directory in the current folder with the same skeleton, but using <code>latest</code> npm references for all dependencies. Commit the directory, push to GitHub, then install it in your Sovereign instance via <code>sv plugin add</code>.</p><h3 id="_3-github-template-repository" tabindex="-1">3. GitHub template repository <a class="header-anchor" href="#_3-github-template-repository" aria-label="Permalink to "3. GitHub template repository""></a></h3><p>Fork <a href="https://github.com/sovereignfs/sovereign-plugin-template" target="_blank" rel="noreferrer"><code>sovereignfs/sovereign-plugin-template</code></a> to create a pre-wired plugin repository with the same skeleton and a CI workflow. Edit the manifest, implement your <code>app/page.tsx</code>, then install with <code>sv plugin add <your-repo-url></code>.</p><h3 id="example-plugins" tabindex="-1">Example plugins <a class="header-anchor" href="#example-plugins" aria-label="Permalink to "Example plugins""></a></h3><p>Reference plugins serve as both documentation and runtime test fixtures. They live in-repo under <a href="./../example-plugins/README.html"><code>example-plugins/</code></a>, tracked in git — browse that directory for fully-working code to adapt. (An earlier plan moved them to a separate <code>sovereign-plugins-examples</code> repository; that was reversed on 2026-08-01 — see <code>docs/epics/example-plugins.md</code>'s correction note.) They're <strong>not</strong> part of the default bundle: <code>example-plugins/</code> is only composed into the build when <code>SOVEREIGN_EXAMPLES_ENABLED</code> is set, and even then hidden by default per-instance until shown from the Console — see <a href="./self-hosting.html#reference-example-plugins">Reference example plugins</a>:</p><table tabindex="0"><thead><tr><th>Plugin ID</th><th>Route</th><th>What it shows</th></tr></thead><tbody><tr><td><code>fs.sovereign.example-basic</code></td><td><code>/example-basic</code></td><td>Session reading, <code>@sovereignfs/ui</code>, CSS tokens, plugin-declared capabilities</td></tr><tr><td><code>fs.sovereign.example-overlay-small</code></td><td><code>/example-overlay-small</code></td><td><code>shell: "overlay"</code> with <code>overlaySize: "sm"</code></td></tr><tr><td><code>fs.sovereign.example-overlay-medium</code></td><td><code>/example-overlay-medium</code></td><td><code>shell: "overlay"</code> with <code>overlaySize: "md"</code></td></tr><tr><td><code>fs.sovereign.example-overlay-large</code></td><td><code>/example-overlay-large</code></td><td><code>shell: "overlay"</code> with <code>overlaySize: "lg"</code></td></tr><tr><td><code>fs.sovereign.example-minimal</code></td><td><code>/example-minimal</code></td><td><code>shell: "minimal"</code> chrome-free/fullscreen composition</td></tr><tr><td><code>fs.sovereign.example-api</code></td><td><code>/example-api</code></td><td>API provider serve-route pattern (PLT-16)</td></tr><tr><td><code>fs.sovereign.example-monetized</code></td><td><code>/example-monetized</code></td><td>Monetization manifest field, Ed25519 license gating, paywall flow (RFC 0003)</td></tr><tr><td><code>fs.sovereign.example-mobile</code></td><td><code>/example-mobile</code></td><td><code>@sovereignfs/ui</code>'s PWA/mobile layout: responsive breakpoint fork, swipeable carousel</td></tr></tbody></table><p>To develop against them locally, set <code>SOVEREIGN_EXAMPLES_ENABLED=1</code> in your <code>.env</code> before <code>pnpm dev</code> — <code>scripts/generate-registry.ts</code> then composes <code>example-plugins/</code> alongside <code>plugins/</code>, with the same copy-on-change dev watcher as any other plugin. See <a href="./repositories.html">Sovereign repositories</a> for the full first-party repository map.</p><p>The <code>example-monetized</code> plugin ships with a committed demo keypair and a pre-signed token, so you can test the paywall → import → access flow immediately without any billing setup. See <a href="#testing-monetization-locally">Testing monetization locally</a> for the step-by-step instructions.</p><h2 id="how-plugins-work" tabindex="-1">How plugins work <a class="header-anchor" href="#how-plugins-work" aria-label="Permalink to "How plugins work""></a></h2><ul><li><strong>Native runtime.</strong> A v1 plugin is plain Next.js App Router code (server components, route handlers, server actions, client components). No iframes, no separate process.</li><li><strong>Build-time composition.</strong> The generate step copies your plugin's <code>app/</code> tree into the runtime's App Router under your manifest's <code>routePrefix</code>, so your routes render inside the platform shell (sidebar, auth, theming) with zero wiring. The copies are generated and git-ignored — your <code>plugins/<id>/app/</code> is always the source of truth.</li><li><strong>The SDK is the only contract.</strong> Auth, database, email, and platform config come from <code>@sovereignfs/sdk</code>. The design system comes from <code>@sovereignfs/ui</code>. Importing from <code>runtime/src</code> is forbidden and enforced by ESLint.</li></ul><h2 id="plugin-isolation-boundary" tabindex="-1">Plugin isolation boundary <a class="header-anchor" href="#plugin-isolation-boundary" aria-label="Permalink to "Plugin isolation boundary""></a></h2><p>What a plugin can do <strong>in isolation</strong> (without a full platform checkout):</p><table tabindex="0"><thead><tr><th>Stage</th><th style="text-align:center;">Isolated?</th><th>Why</th></tr></thead><tbody><tr><td>Author / edit</td><td style="text-align:center;">✅</td><td>Write TypeScript/TSX against <code>@sovereignfs/sdk</code> + <code>@sovereignfs/ui</code> types.</td></tr><tr><td>Type-check / lint</td><td style="text-align:center;">✅</td><td><code>tsc --noEmit</code> / ESLint need only those types — no platform internals required.</td></tr><tr><td>Build as a standalone app</td><td style="text-align:center;">❌</td><td>A plugin's <code>app/</code> is a route-group <strong>fragment</strong> with no root layout, no <code>next.config</code>, no shell.</td></tr><tr><td>Run / test</td><td style="text-align:center;">❌</td><td>Needs the host: middleware-injected headers (<code>getSession</code>), <code>getPlatformDb</code>, the auth server, shell chrome.</td></tr></tbody></table><p><strong>Authoring in a standalone repo:</strong> Install <code>@sovereignfs/sdk</code> and <code>@sovereignfs/ui</code> as devDependencies for types. The SDK is a types-first contract — its implementations are host-provided by the Sovereign runtime when your plugin routes execute. The published package has zero runtime dependencies on platform internals.</p><p><strong>The dev/test loop is always runtime-hosted.</strong> Use <code>sv plugin add <repo></code> or <code>sovereign.plugins.json</code> to run your plugin inside a platform checkout, then <code>pnpm dev</code>. There is no standalone <code>next build</code> or <code>next dev</code> path for a plugin. See <a href="#local-development">Local development</a> below.</p><h2 id="file-structure" tabindex="-1">File structure <a class="header-anchor" href="#file-structure" aria-label="Permalink to "File structure""></a></h2><p>A plugin lives in one directory. Minimal shape (modelled on the built-in <code>account</code> plugin):</p><div class="language- vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang"></span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span>my-plugin/</span></span>
<span class="line"><span> manifest.json # required — identity, routing, capabilities, version (see below)</span></span>
<span class="line"><span> package.json # name, deps (react, @sovereignfs/sdk, @sovereignfs/ui) — version stays "0.0.0", unused</span></span>
<span class="line"><span> icon.svg # optional — sidebar/launcher icon (monogram generated if absent)</span></span>
<span class="line"><span> app/ # composed into the runtime at your routePrefix</span></span>
<span class="line"><span> page.tsx # → <routePrefix>/</span></span>
<span class="line"><span> layout.tsx # optional plugin-level layout</span></span>
<span class="line"><span> actions.ts # 'use server' actions</span></span>
<span class="line"><span> settings/page.tsx # → <routePrefix>/settings</span></span>
<span class="line"><span> _components/ # private components (underscore = not a route)</span></span>
<span class="line"><span> _lib/ # private helpers/tests</span></span>
<span class="line"><span> my-plugin.module.css # CSS Modules + design tokens</span></span>
<span class="line"><span> db/</span></span>
<span class="line"><span> schema.ts # optional — Drizzle tables (slug-prefixed, see Database)</span></span></code></pre></div><p>Anything under <code>app/</code> that isn't an underscore-prefixed folder becomes a route relative to your <code>routePrefix</code>. <code>routePrefix: "/tasks"</code> + <code>app/lists/page.tsx</code> serves at <code>/tasks/lists</code>.</p><h2 id="manifest-reference" tabindex="-1">Manifest reference <a class="header-anchor" href="#manifest-reference" aria-label="Permalink to "Manifest reference""></a></h2><p><code>manifest.json</code> is validated at build time against a strict schema (<code>packages/manifest</code>); unknown keys fail the build. Every field:</p><table tabindex="0"><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td><code>schemaVersion</code></td><td>integer</td><td>yes</td><td>Manifest format version. Currently <code>1</code>.</td></tr><tr><td><code>id</code></td><td>string</td><td>yes</td><td>Globally-unique reverse-DNS id, e.g. <code>io.example.tasks</code>. Also the install directory name.</td></tr><tr><td><code>name</code></td><td>string</td><td>yes</td><td>Human-readable name shown in the sidebar and Launcher.</td></tr><tr><td><code>version</code></td><td>string</td><td>yes</td><td>Plugin version (semver recommended). This is the <strong>only</strong> version the platform reads — the runtime, registry, and export/portability code all key off it. Your <code>package.json</code>'s <code>version</code> field is unrelated tooling metadata; leave it at <code>"0.0.0"</code> and never bump it.</td></tr><tr><td><code>description</code></td><td>string</td><td>no</td><td>Short description.</td></tr><tr><td><code>database</code></td><td><code>shared</code> | <code>isolated</code> | object</td><td>no</td><td>Data isolation model. <code>shared</code> (default) keeps plugin tables in the platform DB with a slug prefix. <code>isolated</code> gives the plugin its own dedicated store — a separate SQLite file or Postgres schema. Object form can request SQLite for an isolated plugin and/or <code>requireEncryption</code> (RFC 0071, raise-only, isolated-only). See the <a href="#database">Database</a> section.</td></tr><tr><td><code>type</code></td><td><code>platform</code> | <code>sovereign</code> | <code>community</code></td><td>yes</td><td>Origin/trust tier (see below).</td></tr><tr><td><code>runtime</code></td><td><code>native</code></td><td>yes</td><td>Execution model. v1 plugins use <code>native</code>. Other runtime models are planned but are not accepted manifest values until implemented.</td></tr><tr><td><code>routePrefix</code></td><td>string starting with <code>/</code></td><td>yes</td><td>URL prefix the plugin serves under, e.g. <code>/tasks</code>. The single source of truth for the plugin's URL.</td></tr><tr><td><code>permissions</code></td><td>array of permission strings</td><td>yes (may be <code>[]</code>)</td><td>SDK capabilities the plugin declares (see below).</td></tr><tr><td><code>shell</code></td><td><code>default</code> | <code>minimal</code> | <code>overlay</code></td><td>no</td><td>Presentation mode. <code>default</code> = full page under the platform sidebar; <code>overlay</code> = dialog over the current page (see below); <code>minimal</code> = chrome-free, full-bleed (see below).</td></tr><tr><td><code>shellConfig</code></td><td>object (see below)</td><td>no</td><td>Per-shell tuning. Holds <code>overlaySize</code> (<code>sm</code> | <code>md</code> | <code>lg</code>, default <code>lg</code>) for <code>shell: overlay</code> plugins, and <code>mobileHeader</code>/<code>mobileFooter</code> (booleans, default <code>true</code>) for <code>shell: default</code> plugins (RFC 0075). Each field is only valid for its own <code>shell</code> value.</td></tr><tr><td><code>adminOnly</code></td><td>boolean</td><td>no (default <code>false</code>)</td><td>When <code>true</code>, only <code>platform:admin</code> users may reach the plugin's routes (403 otherwise).</td></tr><tr><td><code>apiProvider</code></td><td>boolean</td><td>no (default <code>false</code>)</td><td>When <code>true</code>, the plugin serves the public <code>/api/*</code> namespace (PLT-16). One provider per instance — see below.</td></tr><tr><td><code>publicRoutes</code></td><td>array (see below)</td><td>no</td><td>Manifest-declared public page routes (RFC 0042). Each entry exempts a path prefix — relative to <code>routePrefix</code> — from the session-redirect gate; the plugin owns authorization for the exempted paths.</td></tr><tr><td><code>offline</code></td><td>object (see below)</td><td>no</td><td>Offline-capable page routes (RFC 0074). Declares path prefixes — relative to <code>routePrefix</code> — that must render with no network. Grants no auth exemption; the route must render a user-neutral shell and hydrate data client-side via <code>sdk.offline</code>.</td></tr><tr><td><code>example</code></td><td>boolean</td><td>no (default <code>false</code>)</td><td>Marks the plugin as a bundled reference/example. Classification only — no effect on routing or permissions. Example plugins are hidden by default and shown via the Console → Settings → Example plugins toggle; each can also be toggled individually on the Plugins page.</td></tr><tr><td><code>development</code></td><td>boolean</td><td>no (default <code>false</code>)</td><td>Marks the plugin as still under active development — not yet ready for production use. Classification only, like <code>example</code>: no effect on routing, access policy, or the enable/disable default. Surfaced as a warning badge on the Console Plugins page and on the plugin's Launcher tile.</td></tr><tr><td><code>icon</code></td><td>string</td><td>no</td><td>Path to an SVG icon relative to the plugin root. A monogram is generated if omitted.</td></tr><tr><td><code>compatibility</code></td><td>object (see below)</td><td>yes</td><td>Platform version constraints. Hard-gates install/boot on <code>minPlatformVersion</code>; surfaces an advisory warning in Console/health when the platform exceeds the optional <code>maxPlatformVersion</code>.</td></tr><tr><td><code>data</code></td><td>object (see below)</td><td>no</td><td>Cross-plugin data sharing declarations (RFC 0002). Declare the contracts this plugin exposes (<code>data.provides</code>) and the ones it reads (<code>data.consumes</code>). Requires the matching <code>data:provide</code> / <code>data:consume</code> permissions.</td></tr><tr><td><code>env</code></td><td>object (see below)</td><td>no</td><td>Plugin-scoped environment variable declarations (RFC 0018). Keys are auto-namespaced to <code>SV_PLUGIN_<SLUG>_<KEY></code>; read them via <code>sdk.env.get('KEY')</code> in server code.</td></tr><tr><td><code>capabilities</code></td><td>object (see below)</td><td>no</td><td>Plugin-declared capabilities (RFC 0022). Each key is a local name auto-namespaced to <code><pluginId>:<capName></code>; enforce access inside the plugin via <code>sdk.auth.hasCapability</code>.</td></tr><tr><td><code>schedules</code></td><td>array (see below)</td><td>no</td><td>Recurring background schedules (RFC 0046 Phase 1). Each entry names a server-side handler module inside <code>app/</code> that the platform's in-process scheduler invokes every <code>intervalMinutes</code> while the plugin is enabled.</td></tr><tr><td><code>connections</code></td><td>object (see below)</td><td>no</td><td>External provider connection declarations (RFC 0049). Lists OAuth/connect-account providers and callback paths for platform-visible connection metadata.</td></tr><tr><td><code>monetization</code></td><td>object (see below)</td><td>no</td><td>Monetization model (RFC 0003). Declares the billing model, tiers, and the author's Ed25519 public key for offline license verification. Only <code>sovereign</code>/<code>community</code> plugins may declare this.</td></tr><tr><td><code>repository</code></td><td>string (URL)</td><td>required for <code>sovereign</code>/<code>community</code></td><td>Git repository URL. Required unless <code>type</code> is <code>platform</code>.</td></tr></tbody></table><h3 id="future-runtime-models" tabindex="-1">Future runtime models <a class="header-anchor" href="#future-runtime-models" aria-label="Permalink to "Future runtime models""></a></h3><p>The platform currently accepts only <code>runtime: "native"</code> in <code>manifest.json</code>. Earlier design documents reserve <code>static</code>, <code>iframe-local</code>, <code>iframe-remote</code>, and <code>external</code> as future runtime models, but those values intentionally fail manifest validation until the corresponding runtime support ships.</p><h3 id="type" tabindex="-1"><code>type</code> <a class="header-anchor" href="#type" aria-label="Permalink to "`type`""></a></h3><ul><li><code>platform</code> — built-in plugins that ship in this monorepo (Console, Launcher, Account). No <code>repository</code> required.</li><li><code>sovereign</code> — first-party plugins maintained by the project, installed from their own repos. <code>repository</code> required.</li><li><code>community</code> — third-party plugins. <code>repository</code> required.</li></ul><h3 id="permissions" tabindex="-1"><code>permissions</code> <a class="header-anchor" href="#permissions" aria-label="Permalink to "`permissions`""></a></h3><p>Declared SDK capabilities. The v1-functional ones:</p><table tabindex="0"><thead><tr><th>Permission</th><th>Declares</th></tr></thead><tbody><tr><td><code>auth:session</code></td><td>Read the current session via <code>sdk.auth</code>.</td></tr><tr><td><code>db:readWrite</code></td><td>Read/write access to the platform DB via <code>sdk.db</code>.</td></tr><tr><td><code>db:readOnly</code></td><td>Read-only DB access.</td></tr><tr><td><code>mailer:send</code></td><td>Send email via <code>sdk.mailer.send()</code> or <code>sdk.email.sendToUser()</code> (RFC 0062).</td></tr><tr><td><code>mailer:sendExternal</code></td><td>Send email to a raw address (not a platform-resolved user) via <code>sdk.mailer.send()</code> (RFC 0062).</td></tr><tr><td><code>data:provide</code></td><td>Expose read-only data contracts for other plugins to query (RFC 0002, <code>sdk.data</code>).</td></tr><tr><td><code>data:consume</code></td><td>Read data from another plugin's contracts, subject to user consent (RFC 0002, <code>sdk.data</code>).</td></tr><tr><td><code>data:export</code></td><td>Participate in a user's data export bundle — <code>sdk.portability.provideExport()</code> (RFC 0007).</td></tr><tr><td><code>data:import</code></td><td>Participate in a data import/restore — <code>sdk.portability.provideImport()</code> (RFC 0007).</td></tr><tr><td><code>admin:*</code></td><td>Administrative capabilities (platform plugins).</td></tr></tbody></table><p>| <code>activity:write</code> | Record activity-log events via <code>sdk.activity.log()</code> (RFC 0005). |</p><p>| <code>notifications:send</code> | Send notifications to users via <code>sdk.notifications.send()</code> (RFC 0015). |</p><p>| <code>storage:readWrite</code> | Read/write plugin-scoped binary objects via <code>sdk.storage</code> (RFC 0044). |</p><p>Reserved (declaring them is allowed; the backing surfaces throw <code>NotImplementedError</code> until implemented): <code>events:publish</code>, <code>events:subscribe</code>, <code>e2ee:use</code> (client-side encryption, <code>sdk.e2ee</code> — RFC 0060; distinct from any future server-side <code>sdk.crypto.encryptField()</code> field crypto, which the runtime <em>can</em> decrypt).</p><p><code>offline:write</code> (RFC 0078) is validated at the manifest level (requires <code>offline: true</code> on the same manifest) but has no backing SDK surface yet — reserved for the forthcoming offline write/sync capability, see <code>offline</code> below.</p><p>Permission declarations are part of the manifest contract and are used by platform flows such as portability (<code>data:export</code> / <code>data:import</code>) and by the <code>mailer:send</code> / <code>mailer:sendExternal</code> host-side enforcement described below (RFC 0062). Other SDK host surfaces currently rely on the declaration as compatibility metadata rather than a complete runtime authorization boundary; plugins should still declare the permissions they use so future host-side gates can be enforced without changing the manifest.</p><h3 id="plugin-email-sdk-mailer-sdk-email-rfc-0062" tabindex="-1">Plugin email (<code>sdk.mailer</code> / <code>sdk.email</code>, RFC 0062) <a class="header-anchor" href="#plugin-email-sdk-mailer-sdk-email-rfc-0062" aria-label="Permalink to "Plugin email (`sdk.mailer` / `sdk.email`, RFC 0062)""></a></h3><p>The runtime enforces <code>mailer:send</code> at the SDK host boundary: a call to <code>sdk.mailer.send()</code> or <code>sdk.email.sendToUser()</code> from a plugin without that permission throws before any send is attempted. Both methods also require a plugin route context — pass <code>await headers()</code> from <code>next/headers</code> as the second argument, the same convention <code>sdk.notifications.send()</code> uses (unlike <code>sdk.auth</code>/<code>sdk.storage</code>, which read request headers internally). The calling plugin ID is derived from the <code>x-sovereign-plugin-id</code> header on the server side, never from anything in the call's input, so a plugin cannot forge which plugin a send is attributed to.</p><p>There are two methods with different recipient models:</p><ul><li><strong><code>sdk.email.sendToUser({ recipientUserId, templateId, subject, html?, text?, data? })</code></strong> — the recommended default. You supply a user ID; the platform resolves the recipient's email address server-side (through the same directory resolution <code>sdk.directory.resolveUsers()</code> uses), applies delivery policy, records an audit entry in the platform's email delivery log, and returns <code>{ status: 'sent' | 'skipped' | 'failed', errorCode? }</code>. Requires only <code>mailer:send</code>. <code>templateId</code> is recorded for audit/diagnostics; there is no plugin-facing template renderer yet (RFC 0031), so you still supply the final <code>subject</code>/<code>html</code>/<code>text</code> yourself.</li><li><strong><code>sdk.mailer.send({ to, subject, html?, text?, from? })</code></strong> — a low-level escape hatch for a raw recipient address. Because the address doesn't come from the platform's own user resolution, it's treated as an <strong>external</strong> recipient regardless of whether it happens to match a user's email, and additionally requires the <code>mailer:sendExternal</code> permission. Prefer <code>sdk.email.sendToUser()</code> unless you have a genuine reason to email an address the platform doesn't know as a user (e.g. inviting someone who doesn't have an account yet).</li></ul><p>Both methods are rate-limited per plugin and per recipient (a fixed per-process sliding window — a burst of automated or malicious sends from one plugin, or targeting one recipient, is rejected with a "rate limit exceeded" error rather than exhausting SMTP or spamming a user) and go through the same delivery machinery as first-party account/security email — no separate plugin-only mailer path exists. This is distinct from <code>sdk.notifications.send()</code> (RFC 0015): notifications are in-app/push and high-volume by design, while email is comparatively rare, explicitly permissioned, and audited.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { headers } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> 'next/headers'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.email.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">sendToUser</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> recipientUserId,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> templateId: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'export-ready'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> subject: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Your export is ready'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> text: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Your data export has finished. Open the app to download it.'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> headers</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span></code></pre></div><h3 id="apiprovider-and-the-public-api-namespace-plt-16" tabindex="-1"><code>apiProvider</code> and the public <code>/api/*</code> namespace (PLT-16) <a class="header-anchor" href="#apiprovider-and-the-public-api-namespace-plt-16" aria-label="Permalink to "`apiProvider` and the public `/api/*` namespace (PLT-16)""></a></h3><p>The runtime reserves the top-level <code>/api/*</code> namespace for plugin-served <strong>public</strong> APIs. A plugin that sets <code>apiProvider: true</code> becomes the instance's API provider:</p><ul><li>Requests to <code>/api/<slug>/<path></code> are <strong>exempt from the session gate</strong> — the provider owns authentication for them (e.g. API keys). They are <strong>not</strong> redirected to <code>/login</code>.</li><li>The runtime rewrites <code>/api/<slug>/<path></code> to the provider's serve route, <code><routePrefix>/serve/<slug>/<path></code> — implement it as a catch-all route handler at <code>app/serve/[slug]/[[...path]]/route.ts</code>.</li><li><strong>Exactly one</strong> provider is allowed per instance; the build fails if two plugins declare <code>apiProvider: true</code>. With no provider installed (or the provider disabled), <code>/api/*</code> returns <strong>404</strong>.</li><li>The segments the runtime serves itself — <code>account</code>, <code>admin</code>, <code>health</code>, <code>plugins</code> — are reserved and never delegated; a provider must reject them (and any future runtime segment) as slugs.</li></ul><h3 id="publicroutes-—-public-plugin-page-routes-rfc-0042" tabindex="-1"><code>publicRoutes</code> — public plugin page routes (RFC 0042) <a class="header-anchor" href="#publicroutes-—-public-plugin-page-routes-rfc-0042" aria-label="Permalink to "`publicRoutes` — public plugin page routes (RFC 0042)""></a></h3><p><code>apiProvider</code> exempts machine-readable <code>/api/*</code> endpoints from the session gate; <code>publicRoutes</code> does the same for <strong>page</strong> routes — a shared document, a public board, a published read-only view, or a token-protected preview. Unlike <code>apiProvider</code>, any number of plugins may declare <code>publicRoutes</code>; each one only exempts paths under its own <code>routePrefix</code>.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "routePrefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/notes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "publicRoutes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [{ </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"prefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/p"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Token-protected public read-only pages."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><strong>Sub-fields</strong> (each entry):</p><table tabindex="0"><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td><code>prefix</code></td><td>string</td><td>yes</td><td>Path prefix, <strong>relative to <code>routePrefix</code></strong>. Must start with <code>/</code>, must not be <code>/</code>, must not contain <code>..</code> segments or route-group/interception markers (<code>(</code>, <code>)</code>).</td></tr><tr><td><code>description</code></td><td>string</td><td>no</td><td>Human-readable note (docs/Console).</td></tr></tbody></table><p>Given the example above, <code>/notes/p/*</code> is exempt; <code>/notes/<anything else></code> keeps the normal session gate. Prefixes must be unique within a plugin.</p><p><strong>What the platform does:</strong></p><ul><li>CSP and security headers still apply.</li><li>The request is <strong>not</strong> redirected to <code>/login</code> when unauthenticated.</li><li>If a valid session exists, the platform still injects the usual <code>x-sovereign-user-*</code> request headers (so a public route can render differently for a logged-in owner vs. an anonymous visitor); if not, those headers are simply absent.</li><li>A <strong>disabled</strong> plugin's public routes still 404, same as its normal routes.</li><li>A plugin with a paid <code>monetization</code> model blocks anonymous requests to its public routes by default (redirects to <code>/paywall/<id></code>) — there is no <code>paywallExempt</code> escape hatch yet. An authenticated request still goes through the normal entitlement check, so an entitled user reaches the page.</li></ul><p><strong>What your plugin must do</strong> — the platform only decides whether its own gates apply; every public route is otherwise unauthenticated, so the plugin must:</p><ul><li>Validate its own token, public identifier, or share ID <strong>server-side</strong> on every request.</li><li>Return <strong>404</strong> (not 401/403) for anything invalid, expired, revoked, or unknown — a public link must not leak whether the underlying resource exists.</li><li>Render <strong>read-only</strong> by default; never expose mutation controls to an unauthenticated visitor.</li><li>Only fall back to the session (when present) when that user already has normal access to the underlying resource — the session is a convenience for the owner viewing their own share, not a replacement for the token check.</li></ul><p><strong>Example — a token-protected public note preview:</strong></p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// manifest.json</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "routePrefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/notes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "publicRoutes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [{ </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"prefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/p"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Public read-only note previews."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// app/p/[token]/page.tsx</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> default</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> PublicNotePage</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;"> params</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;"> params</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Promise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><{ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">token</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> string</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }>;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">token</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> params;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> share</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> lookupShareByTokenHash</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">hashToken</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(token));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">share </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> share.revokedAt </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (share.expiresAt </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">&&</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> share.expiresAt </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"><</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">())) {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> notFound</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(); </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// 404 — never distinguish "expired" from "never existed"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> note</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> getNoteById</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(share.noteId);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">ReadOnlyNoteView</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> note</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">={</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">note</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">} />;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Recommended fields for a plugin's own share table (this RFC does not define a shared token schema — each plugin owns its model): a <strong>hash</strong> of the token (never the plaintext), the resource ID, who created it, <code>createdAt</code>, <code>expiresAt</code>, <code>revokedAt</code>, and a mode (<code>expiring</code>, <code>permanent</code>, or a plugin-specific enum).</p><h3 id="offline-—-offline-capable-plugins-rfc-0074-rfc-0078" tabindex="-1"><code>offline</code> — offline-capable plugins (RFC 0074, RFC 0078) <a class="header-anchor" href="#offline-—-offline-capable-plugins-rfc-0074-rfc-0078" aria-label="Permalink to "`offline` — offline-capable plugins (RFC 0074, RFC 0078)""></a></h3><p>Sovereign is installable as a PWA, but every page is per-user server-rendered and fetched <code>NetworkFirst</code> — with no network, everything but the static <code>/offline</code> fallback is unreachable. <code>offline: true</code> marks your plugin's bare <code>routePrefix</code> page as its one offline-capable entry point — the shell that loads with no connection, from which your own client-side code takes over.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "routePrefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/launcher"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "offline"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>There is no per-route declaration — <code>offline</code> is a single boolean at the plugin level (RFC 0078 replaced RFC 0074's original <code>offline.routes[]</code>/ <code>offline.root</code> object shape with this flat field; that old shape is no longer accepted). Which screens, lists, or records your plugin actually supports offline is entirely your own client-side decision, invisible to the manifest — the platform only needs to know there's a neutral shell to precache.</p><p><code>offline</code> requires no permission and grants <strong>no auth exemption</strong> — it is purely a caching/rendering declaration, unlike <code>publicRoutes</code>. A separate <code>offline:write</code> permission gates offline write/sync capability (queued mutations applied while offline, synced back once connectivity returns) — declaring it requires <code>offline: true</code>. See "Offline writes (<code>sdk.offline-queue</code>, RFC 0078)" below.</p><p><strong>What the platform does:</strong></p><ul><li>Precaches your plugin's bare-<code>routePrefix</code> document (shell HTML, JS, CSS) the first time it's visited online, so it loads with no network from then on.</li><li>Every other route stays <code>NetworkFirst</code>; offline, it falls back to the <code>/offline</code> "no internet connection" page as usual — only your plugin's one declared entry point is reachable with no connection.</li><li>Never caches or replays per-user API responses — the SW cache holds only the user-neutral shell document.</li></ul><p><strong>What your plugin must do</strong> — the offline-capable route is still a per-user page, but its SSR output must contain <strong>no per-user data</strong>, or a cached copy could be replayed for the wrong user on a shared device:</p><ul><li><p>Render a <strong>user-neutral shell</strong> server-side, in both <code>page.tsx</code> and any wrapping <code>layout.tsx</code> (layout, chrome, empty states, skeletons only — no data fetched during SSR).</p></li><li><p>Hydrate the real data <strong>client-side</strong> from <code>sdk.offline</code> (an IndexedDB-backed cache, imported from the dedicated <code>@sovereignfs/sdk/offline</code> subpath — browser-only modules can't go through the main barrel, same as the E2EE helpers). Pass your plugin's own manifest <code>id</code> as the first argument; you already know it statically, the same way you know your own <code>routePrefix</code>:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'use client'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { offline } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk/offline'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> PLUGIN_ID</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> 'fs.sovereign.wallet'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// On mount: render whatever is cached immediately (works offline).</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> cards</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> offline.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">Card</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">[]>(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">PLUGIN_ID</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'cards'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// When online, fetch fresh data and mirror it for next time offline.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> fresh</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> fetchCards</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> offline.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">set</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">PLUGIN_ID</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'cards'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, fresh);</span></span></code></pre></div></li><li><p>Show a plugin-owned empty state ("Not available offline yet — open once online") when the cache has nothing stored, rather than a blank page.</p></li><li><p>Keep each cached value reasonably sized: <code>offline.set</code> enforces a 5 MB soft cap per entry and throws <code>OfflineQuotaExceededError</code> (also thrown if the browser's origin storage quota — shared across every installed plugin's offline cache — is exhausted). Split large data across multiple keys rather than one large entry.</p></li><li><p>Everything past the one entry point — which screens or records to show, how to route between them — is your own client-side code. There is no platform mechanism for a second offline-reachable route; a bookmark or link to anything other than your plugin's bare <code>routePrefix</code> is not SW-served offline and falls through to the generic <code>/offline</code> fallback.</p></li></ul><p><strong>CI-enforced:</strong> <code>runtime/src/__tests__/offline-route-neutrality.test.ts</code> statically scans every offline-enabled plugin's root <code>page.tsx</code>/<code>layout.tsx</code> (and any co-located <code>_</code>-prefixed helper files they import) for identity-reading APIs (<code>headers()</code>, <code>cookies()</code>, a session helper, the <code>x-sovereign-user-id</code> header) and fails the build if any are found — it is a source scan, not a rendered-output diff, so it cannot catch every possible per-user leak, but it catches the direct ones before your route ships.</p><p><strong>Isolation note:</strong> <code>sdk.offline</code> scopes entries by plugin id only, not by user id — an offline route's own SSR output must never carry per-user data (that's the whole point of the user-neutral-shell rule above), so there's no safe client-side signal to key by user identity. Isolation across a login boundary instead comes from the runtime calling <code>offline.clearAll()</code> on every logout/user-switch: nothing cached ever survives past the session that wrote it, which is what makes plugin-only scoping safe on a shared device.</p><h3 id="offline-writes-sdk-offline-queue-rfc-0078" tabindex="-1">Offline writes (<code>sdk.offline-queue</code>, RFC 0078) <a class="header-anchor" href="#offline-writes-sdk-offline-queue-rfc-0078" aria-label="Permalink to "Offline writes (`sdk.offline-queue`, RFC 0078)""></a></h3><p><code>sdk.offline</code> above is a <strong>read-only</strong> cache. <code>@sovereignfs/sdk/offline-queue</code> adds a client-side mutation queue on top of it, so a plugin declaring <code>offline: true</code> plus the <code>offline:write</code> permission can also let a user add, edit, or delete data while offline — queuing each write and syncing it back once connectivity returns.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "routePrefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/shopper"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "offline"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "permissions"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"auth:session"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"db:readWrite"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"offline:write"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Like <code>sdk.offline</code>, this is a <strong>generic, plugin-agnostic capability</strong> — it takes a <code>pluginId</code> and plugin-chosen operation names/payloads, and knows nothing about any particular plugin's data model. It is a separate IndexedDB-backed module from <code>sdk.offline</code> (its own database, its own <code>clearAll()</code> purge), imported from its own subpath for the same client/server-boundary reason as every other browser-only SDK module:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'use client'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { offlineQueue, drainQueue } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk/offline-queue'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> PLUGIN_ID</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> 'fs.sovereign.shopper'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// While offline (or optimistically, even online): apply the change to your</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// own local view immediately, then queue it.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> offlineQueue.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">enqueue</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">PLUGIN_ID</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'setBought'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, { itemId, bought: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, at: Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">/</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> 1000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> });</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Later — on mount, or a `window` 'online' handler — drain the queue against</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// your own sync endpoint.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> drainQueue</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">PLUGIN_ID</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">batch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> res</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> fetch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'/shopper/api/sync'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> method: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'POST'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> body: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">JSON</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">stringify</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ mutations: batch }),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> });</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">outcomes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> res.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">json</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> outcomes; </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// [{ id, status: 'applied' | 'skipped' | 'failed', error? }]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p><strong>The apply contract your sync endpoint must implement</strong> (this is the part that makes retries and last-write-wins safe, not <code>sdk.offline-queue</code> itself — the queue only stores and drains; your endpoint decides what each operation means):</p><ul><li><strong>Every operation must be idempotent and absolute, never a delta or a toggle.</strong> A create should carry a client-minted permanent id (so a retried <code>INSERT ... ON CONFLICT DO NOTHING</code> is safe and there's no temp-id reconciliation step). An update should carry the full new field values, not a diff. A boolean flip (e.g. "mark bought") must carry the <strong>intended end state</strong>, not "toggle it" — a lost response followed by a client retry would otherwise flip a real toggle twice.</li><li><strong>Last-write-wins via a server-side timestamp comparison, checked on every apply attempt</strong> — compare the mutation's <code>clientTimestamp</code> (epoch seconds) against the target row's own last-modified timestamp; apply only if the mutation is newer. Every <em>online</em> write path that touches the same rows must also maintain that timestamp column, or an online edit's stale value will wrongly lose to a later-synced offline mutation.</li><li><strong>Apply sequentially and halt at the first failure</strong> within a batch, so a dependent later mutation (e.g. editing an item some earlier queued mutation in the same batch was meant to create) is never attempted out of order. Mutations your endpoint never reaches this round simply stay queued for the next drain.</li><li>Return <code>'skipped'</code> (not <code>'failed'</code>) when a mutation's target was already removed by someone else — there's nothing to reconcile, and the client's next full re-fetch will drop it from view. Return <code>'failed'</code> with a clear <code>error</code> when the mutation genuinely couldn't be applied (e.g. its parent list was deleted) — silently dropping a user's change is worse than surfacing it.</li></ul><p><code>offlineQueue.enqueue()</code> throws <code>OfflineQueueFullError</code> past a 500-entry soft cap per plugin — there's no eviction, since silently dropping a queued write is data loss. <code>offlineQueue.clearAll()</code> is purged on every logout/login boundary alongside <code>offline.clearAll()</code>, the same shared-device safeguard — a plugin adopting offline writes should attempt a best-effort <code>drainQueue()</code> before sign-out completes when online, since the purge is destructive to anything not yet synced.</p><p><strong>Known limitation, stated plainly:</strong> sync only ever runs while a plugin's own client code is mounted and calls <code>drainQueue()</code> — there is no platform-orchestrated background sync (the Background Sync API has no iOS Safari support). If the app isn't open when connectivity returns, queued writes wait until it is.</p><h3 id="shell-overlay-rfc-0001" tabindex="-1"><code>shell: overlay</code> (RFC 0001) <a class="header-anchor" href="#shell-overlay-rfc-0001" aria-label="Permalink to "`shell: overlay` (RFC 0001)""></a></h3><p>An <code>overlay</code> plugin renders as a <strong>dismissable dialog over the current page</strong> instead of a full-page navigation — ideal for settings, quick-capture, or pickers the user opens mid-task and wants to dismiss back to where they were.</p><p>You write ordinary pages; the platform handles the rest:</p><ul><li>A soft (in-app) navigation to the plugin opens it in a dialog layered over the current page, which stays mounted (no lost scroll/state). Navigating between the plugin's own sub-routes stays inside the dialog.</li><li>A hard load (deep link, refresh, post-login redirect) renders the same pages as a normal full page — the URL is identical either way.</li><li>The runtime owns the dialog chrome (scrim, close button, Esc/scrim-click dismissal); your pages never implement a modal shell.</li><li>The dialog size is set by <code>shellConfig.overlaySize</code> (<code>sm</code> | <code>md</code> | <code>lg</code>, default <code>lg</code>).</li></ul><p><strong>Intra-overlay navigation must use <code>replace</code>.</strong> The dialog is dismissed with <code>router.back()</code>, which unwinds exactly one history entry. If your in-dialog tab or section links push new entries (the <code><Link></code> default), each one stacks on history and a single dismiss only steps back one tab instead of closing the dialog. Use <code><Link replace></code> (or <code>router.replace</code>) for navigation <em>within</em> an overlay plugin so closing always returns to the page the dialog opened over.</p><p>Constraints: an overlay plugin's <code>routePrefix</code> must be a <strong>single segment</strong> (e.g. <code>/account</code>), and an overlay plugin is <strong>not eligible as the root plugin</strong> (CON-11) — the root serves <code>/</code> as a full page.</p><h3 id="shell-minimal-rfc-0014" tabindex="-1"><code>shell: minimal</code> (RFC 0014) <a class="header-anchor" href="#shell-minimal-rfc-0014" aria-label="Permalink to "`shell: minimal` (RFC 0014)""></a></h3><p>A <code>minimal</code> plugin renders <strong>chrome-free and full-bleed</strong> — no sidebar, no header, no footer. The plugin owns the entire viewport. Useful for kiosk displays, immersive media players, or any full-screen experience where the platform shell would be intrusive.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{ </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"shell"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"minimal"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span></code></pre></div><ul><li>The plugin composes into <code>runtime/app/(minimal)/</code> so it inherits a simple, chrome-free layout (<code>100dvh</code>, safe-area insets).</li><li>The <strong>session gate still applies</strong> — the middleware enforces authentication before the plugin renders. <code>minimal</code> does not bypass auth.</li><li>Unlike <code>overlay</code>, a multi-segment <code>routePrefix</code> is allowed (e.g. <code>/kiosk/display</code>).</li><li>A <code>minimal</code> plugin <strong>may be configured as the root plugin</strong> (kiosk use case). When set as root, <code>/</code> renders the plugin full-bleed — be aware there is no nav affordance back to the Launcher or other plugins unless the plugin provides it.</li></ul><p><strong>Nav convention for minimal root plugins:</strong> if your plugin is the root and other plugins are installed, provide your own navigation (a menu, a link to <code>/launcher</code>, etc.). The platform shell is absent, so users have no other way to reach the Launcher or Console.</p><h3 id="mobile-header-footer-toggle-rfc-0075" tabindex="-1">Mobile header/footer toggle (RFC 0075) <a class="header-anchor" href="#mobile-header-footer-toggle-rfc-0075" aria-label="Permalink to "Mobile header/footer toggle (RFC 0075)""></a></h3><p>A <code>shell: default</code> plugin can independently hide its <strong>mobile header</strong> and/or <strong>mobile footer</strong> via <code>shellConfig</code>, both defaulting to <code>true</code> (today's behavior). Useful for a mobile-first view — a chat thread, a canvas, a media viewer — that wants more of the viewport without giving up the sidebar (or the rest of the default shell) the way <code>shell: minimal</code> does.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "shell"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"default"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "shellConfig"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"mobileHeader"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"mobileFooter"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">false</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><ul><li><strong>Desktop is never affected.</strong> The sidebar always renders regardless of these fields — this is a mobile-only, per-piece toggle.</li><li>Only valid when <code>shell</code> is <code>default</code> (or omitted, since <code>default</code> is the implicit value) — setting either field under <code>minimal</code> or <code>overlay</code> fails manifest validation.</li><li>The omitted element is removed from the page entirely, not CSS-hidden — no flash of chrome-then-removal, no wasted hydration.</li><li><strong>If you hide both</strong>, your plugin loses the platform's mobile nav (Home / Apps / Search) for that screen — the same navigation contract as <code>shell: minimal</code>: your plugin is responsible for providing its own way back (e.g. a header affordance if you keep the header, or an in-page one if you hide both). The platform does not inject an escape hatch.</li><li>This is a <strong>per-plugin</strong> setting, not per-route — every page under the plugin's <code>routePrefix</code> gets the same header/footer visibility. A plugin that needs per-screen variation (e.g. list view keeps the footer, detail view doesn't) is a <code>shell: minimal</code> candidate instead.</li></ul><h3 id="compatibility-rfc-0024" tabindex="-1"><code>compatibility</code> (RFC 0024) <a class="header-anchor" href="#compatibility-rfc-0024" aria-label="Permalink to "`compatibility` (RFC 0024)""></a></h3><p>Every manifest must declare a <code>compatibility</code> object that tells the platform what versions it can run on:</p><table tabindex="0"><thead><tr><th>Sub-field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td><code>minPlatformVersion</code></td><td>semver</td><td>yes</td><td>The oldest platform version this plugin supports. <strong>Hard-enforced</strong> — the plugin is disabled at install, build, and boot if the running platform is below this version.</td></tr><tr><td><code>maxPlatformVersion</code></td><td>semver</td><td>no</td><td>The newest platform version the plugin has been tested against. <strong>Advisory only</strong> — the plugin still loads on a newer platform, but Console and the health endpoint surface a warning so the operator knows the plugin is running in untested territory.</td></tr></tbody></table><p>Both values must be valid <a href="https://semver.org/" target="_blank" rel="noreferrer">semver</a> strings (e.g. <code>"0.6.0"</code>).</p><p><strong>Enforcement tiers:</strong></p><ol><li><strong>Build</strong> — <code>pnpm generate</code> / <code>scripts/generate-registry.ts</code> rejects a plugin that declares a <code>minPlatformVersion</code> higher than the running platform, so a CI build fails before producing an incompatible image.</li><li><strong>Install</strong> — <code>sv plugin add</code> and <code>scripts/install-plugins.ts</code> reject the plugin with a human-readable error.</li><li><strong>Boot</strong> — on startup, the runtime checks every installed plugin; incompatible ones are <strong>disabled in <code>plugin_status</code></strong> (same effect as an operator pressing Disable in Console) and a reason is surfaced in the Console Plugins page ("Incompatible — cannot enable") and the admin health endpoint (<code>incompatiblePlugins[]</code>).</li></ol><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"compatibility"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "minPlatformVersion"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"0.5.0"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "maxPlatformVersion"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"1.0.0"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Set <code>minPlatformVersion</code> to the earliest platform release your plugin was built and tested against. Omit <code>maxPlatformVersion</code> unless you have a specific reason to warn operators (e.g. the next major uses a breaking SDK change).</p><h3 id="data-—-cross-plugin-data-sharing-rfc-0002" tabindex="-1"><code>data</code> — cross-plugin data sharing (RFC 0002) <a class="header-anchor" href="#data-—-cross-plugin-data-sharing-rfc-0002" aria-label="Permalink to "`data` — cross-plugin data sharing (RFC 0002)""></a></h3><p>Declare the contracts your plugin exposes or reads. Both directions are consent-gated: the current user must explicitly grant a consumer permission to read a provider's data. Consent is managed in the <strong>Account → Data</strong> tab.</p><p><strong>Sub-fields:</strong></p><table tabindex="0"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td><code>data.provides</code></td><td>array</td><td>Contracts this plugin exposes. Each entry: <code>contract</code> (string), <code>version</code> (int), <code>description</code> (optional string).</td></tr><tr><td><code>data.consumes</code></td><td>array</td><td>Contracts this plugin reads. Each entry: <code>providerId</code> (manifest id), <code>contract</code> (string), <code>version</code> (int).</td></tr></tbody></table><p><strong>Provider</strong> — expose a contract and register its resolver:</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"permissions"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"db:readWrite"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"data:provide"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">],</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"data"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "provides"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"contract"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"expenses"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"version"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Expense records for this user."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// In a Server Component or route handler that runs when the plugin loads:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">sdk.data.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">provide</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'expenses'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">since</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">since</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">?:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> string</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> db</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getClient</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> db.query.expenses.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">findMany</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> where</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">gte</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (since </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">?</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> gte</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(t.date, since) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> undefined</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> });</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p><strong>Consumer</strong> — declare what you read and query it (throws <code>ConsentRequiredError</code> when the user has not yet granted consent):</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"permissions"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"data:consume"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">],</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"data"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "consumes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"providerId"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"com.example.finance"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"contract"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"expenses"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"version"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { ConsentRequiredError } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">try</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> rows</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.data.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">query</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { providerId: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'com.example.finance'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, contract: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'expenses'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, version: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { since: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'2025-01-01'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> );</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">} </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">catch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (e) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (e </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">instanceof</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> ConsentRequiredError</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) {</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // Direct the user to Account → Data to grant consent.</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><strong>Resolver registration timing:</strong> resolvers are in-process and reset on server restart. Call <code>sdk.data.provide()</code> from a server-side handler (Server Component, Route Handler) that executes when the plugin is first loaded. Consumers can only query after the provider has registered — if you receive a resolver-not-found error, the provider plugin has not yet served a request in the current process.</p><h3 id="integrations-—-optional-sibling-plugin-integrations-rfc-0051" tabindex="-1"><code>integrations</code> — optional sibling-plugin integrations (RFC 0051) <a class="header-anchor" href="#integrations-—-optional-sibling-plugin-integrations-rfc-0051" aria-label="Permalink to "`integrations` — optional sibling-plugin integrations (RFC 0051)""></a></h3><p>Purely informational metadata for install/discovery UX (Console, Account, plugin UI hints) — declaring one here grants nothing by itself and is never an install blocker. To actually read another plugin's data you still need <code>data.consumes</code> + the <code>data:consume</code> permission + user consent (RFC 0002); use <code>sdk.plugins.get()</code>/<code>list()</code> at runtime to check whether the sibling is installed, enabled, and available before offering the integration.</p><p><strong>Sub-fields:</strong></p><table tabindex="0"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td><code>integrations.optional</code></td><td>array</td><td>Sibling plugins this plugin can integrate with if present.</td></tr></tbody></table><p>Each entry: <code>provider</code> (the sibling's manifest <code>id</code>), <code>reason</code> (human-readable, shown in install/discovery UI), <code>contracts</code> (optional array of data contract names this integration would consume), <code>tools</code> (optional array of RFC 0047 tool names this integration would invoke — reserved, RFC 0047 not yet implemented).</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"integrations"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "optional"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "provider"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"io.example.crm"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "reason"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Link records to contacts"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "contracts"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"crm.contacts"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><h3 id="env-—-plugin-scoped-environment-variables-rfc-0018" tabindex="-1"><code>env</code> — plugin-scoped environment variables (RFC 0018) <a class="header-anchor" href="#env-—-plugin-scoped-environment-variables-rfc-0018" aria-label="Permalink to "`env` — plugin-scoped environment variables (RFC 0018)""></a></h3><p>Plugins can declare environment variables in the manifest <code>env</code> object. Each key must be <code>UPPER_CASE</code>. The platform auto-namespaces them so they cannot collide with platform or other-plugin vars.</p><table tabindex="0"><thead><tr><th>Sub-field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td><code>description</code></td><td>string</td><td>yes</td><td>Human-readable description shown to operators.</td></tr><tr><td><code>scope</code></td><td><code>runtime</code> | <code>build</code></td><td>yes</td><td><code>runtime</code> → <code>SV_PLUGIN_<SLUG>_<KEY></code> (server-side only). <code>build</code> → <code>NEXT_PUBLIC_SV_PLUGIN_<SLUG>_<KEY></code> (inlined at <code>next build</code>; do not use for secrets).</td></tr><tr><td><code>required</code></td><td>boolean</td><td>no</td><td>When <code>true</code>, the platform warns at startup if the var is absent.</td></tr><tr><td><code>secret</code></td><td>boolean</td><td>no</td><td>When <code>true</code>, the value must never appear in the manifest <code>default</code> or the plugin's <code>.env</code> file.</td></tr><tr><td><code>default</code></td><td>string</td><td>no</td><td>Default value applied when the var is absent. Not allowed on <code>secret</code> vars.</td></tr></tbody></table><p><strong>Example declaration:</strong></p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "env"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "API_KEY"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Third-party API key"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "secret"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "scope"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"runtime"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "required"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "API_URL"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"API base URL"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "scope"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"runtime"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "default"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"https://api.example.com"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "MAP_TOKEN"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Public map token"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"scope"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"build"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>The effective namespaced keys for a plugin with <code>id: "io.example.tasks"</code>:</p><table tabindex="0"><thead><tr><th>Manifest key</th><th>Namespaced key</th></tr></thead><tbody><tr><td><code>API_KEY</code></td><td><code>SV_PLUGIN_IO_EXAMPLE_TASKS_API_KEY</code></td></tr><tr><td><code>API_URL</code></td><td><code>SV_PLUGIN_IO_EXAMPLE_TASKS_API_URL</code></td></tr><tr><td><code>MAP_TOKEN</code></td><td><code>NEXT_PUBLIC_SV_PLUGIN_IO_EXAMPLE_TASKS_MAP_TOKEN</code></td></tr></tbody></table><p><strong>Reading vars in server code</strong> (<code>scope: "runtime"</code>):</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// In a Server Component, Route Handler, or Server Action:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> apiKey</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.env.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'API_KEY'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">); </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// → string | null</span></span></code></pre></div><p><code>sdk.env.get</code> reads <code>SV_PLUGIN_<SLUG>_<KEY></code> scoped to the calling plugin (determined from the <code>x-sovereign-plugin-id</code> header the middleware injects). A plugin can only read its own declared vars — not platform vars or other plugins' vars.</p><p><strong>Reading build-scope vars</strong> (<code>scope: "build"</code>) in client components:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Client Component — use process.env directly (Next.js inlines NEXT_PUBLIC_* at build time).</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Replace IO_EXAMPLE_TASKS with your plugin's derived slug.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> token</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> process.env.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">NEXT_PUBLIC_SV_PLUGIN_IO_EXAMPLE_TASKS_MAP_TOKEN</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span></code></pre></div><p><strong>Operator setup:</strong> operators set secret vars in the container environment before starting the platform. The platform logs a warning at startup for any <code>required</code> vars that are absent.</p><p><strong>Dev workflow:</strong> create a <code>plugins/<dir>/.env</code> file (gitignored) for local non-secret values. The generate script reads it and merges it as defaults. Secret vars must always be set in the actual environment — never in <code>.env</code>.</p><h3 id="runtime-secrets-sdk-secrets-rfc-0043" tabindex="-1">Runtime secrets (<code>sdk.secrets</code>, RFC 0043) <a class="header-anchor" href="#runtime-secrets-sdk-secrets-rfc-0043" aria-label="Permalink to "Runtime secrets (`sdk.secrets`, RFC 0043)""></a></h3><p>Use plugin-scoped env vars for deployment-time secrets that operators supply before startup. Use <code>sdk.secrets</code> for secrets created at runtime, such as OAuth refresh tokens, personal access tokens, webhook signing secrets, and per-user API keys.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> ref</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.secrets.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">create</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> scope: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'user'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> label: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'GitHub connection'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> value: refreshToken,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadata: { provider: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'github'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> token</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.secrets.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(ref.id);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.secrets.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">update</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(ref.id, rotatedRefreshToken);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.secrets.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">delete</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(ref.id);</span></span></code></pre></div><p>Scopes:</p><table tabindex="0"><thead><tr><th>Scope</th><th>Use for</th></tr></thead><tbody><tr><td><code>user</code></td><td>A credential for the current user and calling plugin.</td></tr><tr><td><code>plugin</code></td><td>A runtime secret shared by the calling plugin across users.</td></tr><tr><td><code>instance</code></td><td>Instance-wide plugin configuration; requires <code>instance:configure</code>.</td></tr></tbody></table><p><code>sdk.secrets.list(scope?)</code> returns metadata-only refs. Plaintext values are never returned by list calls, never exported, and never shown in Account UI. Account deletion hard-deletes user-scoped vault rows. User exports include metadata only so users can see which connections need to be re-created after import.</p><h3 id="external-connections-sdk-connections-rfc-0049" tabindex="-1">External connections (<code>sdk.connections</code>, RFC 0049) <a class="header-anchor" href="#external-connections-sdk-connections-rfc-0049" aria-label="Permalink to "External connections (`sdk.connections`, RFC 0049)""></a></h3><p>Use <code>sdk.connections</code> for runtime connection metadata around external accounts or providers. Store credential material first with <code>sdk.secrets</code>; store only the returned secret reference and sanitized provider metadata on the connection row.</p><div class="language-jsonc vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsonc</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "connections"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "providers"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"email.google"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "title"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Google Mail"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "callbackPath"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/connections/google/callback"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "scopes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"https://www.googleapis.com/auth/gmail.readonly"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">],</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "config"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "public"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "clientId"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "label"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Client ID"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "env"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"GOOGLE_CLIENT_ID"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "required"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "secrets"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "clientSecret"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "label"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Client secret"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "env"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"GOOGLE_CLIENT_SECRET"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "required"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ],</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><code>scopes</code> is a free-form list of provider-defined OAuth/API scope identifiers (e.g. GitHub's <code>"repo"</code>, <code>"read:user"</code>) — not the <code>sdk.secrets</code> storage-scope enum (<code>user</code> / <code>plugin</code> / <code>instance</code>), which is a different, unrelated concept. It's the manifest-declared default; an admin can override the effective scopes for a connection independently.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> state</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.connections.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">createOAuthState</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> provider: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'email.google'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> callbackPath: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'/connections/google/callback'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// In the callback route, validate `state`, exchange the provider code</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// server-side, save tokens in the vault, then create/update metadata.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.connections.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">verifyOAuthState</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(stateFromProvider);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> secret</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.secrets.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">create</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> scope: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'user'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> label: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Google Mail tokens'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> value: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">JSON</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">stringify</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(tokens),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadata: { provider: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'email.google'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.connections.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">create</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> scope: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'user'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> provider: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'email.google'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> label: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Google Mail'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> secretRef: secret.id,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadata: { account: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'user@example.com'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p>OAuth state tokens are signed, expiry-bound, tied to the calling plugin and current user, and rejected after successful validation in the running process. Disconnecting a connection removes the associated vault secret reference and soft-deletes the linked secret where possible. Provider-side token revocation is plugin-owned; call the provider first, then <code>sdk.connections.disconnect(id)</code>. Token refresh failures should call <code>sdk.connections.markError(id, { error, status: 'needs_reauth' })</code> with sanitized messages only. Account and Console show connection metadata and status; they never show credentials.</p><p>Provider declarations may include <code>config.public</code> and <code>config.secrets</code> maps for instance-level settings such as OAuth client IDs and client secrets. Console shows those fields to admins, displays the absolute callback URL, stores public values in platform metadata, and stores secret values through the plugin secret vault. Field <code>env</code> names are plugin-scoped runtime fallbacks; for the example above, <code>GOOGLE_CLIENT_ID</code> resolves as <code>SV_PLUGIN_<PLUGIN_SLUG>_GOOGLE_CLIENT_ID</code>. Console-managed values take precedence over env-provided values, so operators can rotate credentials without changing deployment env vars or restarting the app. Leaving a secret input blank keeps the stored secret; submitting a new value rotates the vault entry. Removing the provider config deletes the linked vault secret reference.</p><p>Read the effective provider config server-side:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> config</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.connections.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getProviderConfig</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'email.google'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">config.configured) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> throw</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Error</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">`Google Mail is not configured: ${</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">config</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">.</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">missingRequired</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">join</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">', '</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">)</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">}`</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> params</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> URLSearchParams</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> client_id: config.publicValues.clientId,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> redirect_uri: config.callbackUrl </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">??</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> ''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> scope: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'https://www.googleapis.com/auth/gmail.readonly'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p><code>getProviderConfig()</code> is scoped to the calling plugin from the request context, so a plugin cannot read another plugin's provider settings. Secret values are returned only to server-side plugin code through this SDK call; they are not included in Console reads, activity logs, exports, generated files, or plugin tables. Test failures and provider errors should be sanitized before storing them with <code>sdk.connections.markError()</code> or showing them in Console.</p><h3 id="capabilities-—-plugin-declared-capabilities-rfc-0022" tabindex="-1"><code>capabilities</code> — plugin-declared capabilities (RFC 0022) <a class="header-anchor" href="#capabilities-—-plugin-declared-capabilities-rfc-0022" aria-label="Permalink to "`capabilities` — plugin-declared capabilities (RFC 0022)""></a></h3><p>Plugins can declare their own fine-grained capabilities that gate features inside the plugin. Each key is a <strong>local capability name</strong> (lowercase kebab-case); the platform auto-namespaces it to <code><pluginId>:<capName></code> to keep names globally unique.</p><table tabindex="0"><thead><tr><th>Sub-field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td><code>description</code></td><td>string</td><td>no</td><td>Human-readable description of what the capability grants.</td></tr><tr><td><code>defaultGrant</code></td><td><code>'all'</code> | <code>'none'</code></td><td>no</td><td>Who gets the capability by default. See below. Defaults to <code>'none'</code> if absent.</td></tr></tbody></table><p><strong><code>defaultGrant</code> values:</strong></p><ul><li><code>'all'</code> — every authenticated user automatically receives the capability. The platform injects it into <code>session.user.capabilities</code> alongside the platform-role capabilities, so <code>sdk.auth.hasCapability(session, cap)</code> works without any DB call.</li><li><code>'none'</code> (default) — no one is granted the capability by default. The plugin manages grants itself — use <code>sdk.db</code> to store per-user grants in the plugin's own table and check them with <code>sdk.auth.hasCapability</code> after loading the grant from the DB.</li></ul><p><strong>Enforcement is inside the plugin, not the platform route gate.</strong> The platform never blocks a route because a plugin capability is absent — it only injects the capabilities list. Plugins enforce feature access in their own server components or API routes:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// In a Server Component or route handler:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// The namespaced capability: '<pluginId>:<localName>'</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> CAP_CREATE</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> 'com.acme.myapp:create-item'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> default</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Page</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> session</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.auth.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getSession</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">sdk.auth.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">hasCapability</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(session, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">CAP_CREATE</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">)) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">p</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>You do not have permission to create items.</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"></</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">p</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // ... render the guarded UI</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><strong>Manifest example:</strong></p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"capabilities"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "create-item"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Create items in the list."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "defaultGrant"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"all"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "admin-panel"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Access the admin configuration panel."</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>In this example, <code>com.acme.myapp:create-item</code> is granted to all users automatically; <code>com.acme.myapp:admin-panel</code> is not granted by default and the plugin must manage who receives it.</p><h3 id="notifications-—-notification-center-rfc-0015" tabindex="-1"><code>notifications</code> — Notification Center (RFC 0015) <a class="header-anchor" href="#notifications-—-notification-center-rfc-0015" aria-label="Permalink to "`notifications` — Notification Center (RFC 0015)""></a></h3><p>Plugins can send in-app notifications to users by declaring the <code>notifications:send</code> permission and calling <code>sdk.notifications.send()</code>. Notifications appear in the bell icon in the platform chrome; users see toasts for new items and can manage preferences in <strong>Account → Notifications</strong>.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "permissions"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"notifications:send"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Inside a plugin server action or route handler (server-side only):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { headers } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> 'next/headers'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.notifications.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">send</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> recipientUserId: userId,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> title: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Your export is ready'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> body: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Click to download your data archive.'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> url: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'/myPlugin/exports'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> category: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'info'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// 'info' | 'announcement' | 'security' | custom</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // icon is optional — a URL to an image shown in the OS push notification.</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // Defaults to your plugin's own /plugin-icons/<id>.svg; only set it to</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // override with a notification-specific image instead.</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> headers</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(), </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// pass the request headers so the runtime can read the plugin ID</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span></code></pre></div><p><strong>Categories and muting:</strong></p><table tabindex="0"><thead><tr><th>Category</th><th>Notes</th></tr></thead><tbody><tr><td><code>info</code></td><td>Default. Users can mute.</td></tr><tr><td><code>announcement</code></td><td>Admin-broadcast category. Users can mute.</td></tr><tr><td><code>security</code></td><td>High-priority (password change, MFA change). <strong>Cannot be muted.</strong></td></tr><tr><td><em>custom</em></td><td>Any other string. Users can mute.</td></tr></tbody></table><p><strong>Runtime enforcement:</strong> the <code>source</code> and <code>sourceType</code> fields are stamped by the runtime from the calling plugin's <code>x-sovereign-plugin-id</code> header — plugins cannot forge sender identity.</p><p><strong>Web Push fan-out (RFC 0016):</strong> when an operator configures VAPID keys, the platform automatically delivers a background push notification to every subscribed device for the recipient — on top of the in-app bell delivery. Plugins call the same <code>sdk.notifications.send()</code> API regardless; the push fan-out is invisible and requires no plugin changes. Users opt in and out per-device via <strong>Account → Notifications → Enable push notifications</strong>.</p><h3 id="schedules-—-recurring-background-jobs-rfc-0046-phase-1" tabindex="-1"><code>schedules</code> — recurring background jobs (RFC 0046 Phase 1) <a class="header-anchor" href="#schedules-—-recurring-background-jobs-rfc-0046-phase-1" aria-label="Permalink to "`schedules` — recurring background jobs (RFC 0046 Phase 1)""></a></h3><p>Plugins can declare recurring server-side jobs that run without any browser request — e.g. sending scheduled reminders, cleaning up expired rows, or refreshing cached data. The platform's in-process scheduler invokes each declared handler every <code>intervalMinutes</code> while the plugin is installed and enabled.</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"schedules"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"due-reminders"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"intervalMinutes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"entry"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"app/_jobs/due-reminders.ts"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">]</span></span></code></pre></div><table tabindex="0"><thead><tr><th>Field</th><th>Notes</th></tr></thead><tbody><tr><td><code>id</code></td><td>Stable schedule identifier, unique within the plugin (lowercase kebab-case).</td></tr><tr><td><code>intervalMinutes</code></td><td>Minimum minutes between invocations (integer ≥ 1). A floor, not an exact cadence.</td></tr><tr><td><code>entry</code></td><td>Handler module path relative to the plugin root, inside <code>app/</code>. Must be a <code>.ts</code> module; use an underscore-prefixed directory (e.g. <code>app/_jobs/</code>) so it never becomes a route.</td></tr></tbody></table><p>The entry module's <strong>default export</strong> is a <code>ScheduleHandler</code> from <code>@sovereignfs/sdk</code>:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// app/_jobs/due-reminders.ts</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">type</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ScheduleContext } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> default</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> dueReminders</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">ctx</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> ScheduleContext</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">)</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Promise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> db</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">db</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // …query your plugin's tables, then notify:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.notifications.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">send</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { recipientUserId: userId, title: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'Task due'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, url: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'/tasks'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ctx.headers, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// synthetic headers carrying this plugin's identity</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> );</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><strong>Handlers must be idempotent.</strong> Phase 1 is deliberately not a job queue: there is no persistence, no retries, and no backoff. The last-run marker lives in memory, so a restarted instance re-arms every schedule, and each replica of a multi-node deployment ticks independently — claim work with conditional updates (e.g. <code>UPDATE … WHERE sent_at IS NULL</code>) before acting on it, and only act when the claim succeeded. Thrown errors are caught and logged; the failed schedule waits out its own interval before running again.</p><p><strong>No originating request.</strong> There is no session and no user in scope — handlers run as the plugin itself. <code>ctx.headers</code> carries the plugin's identity for SDK surfaces that attribute by request headers (<code>sdk.notifications.send</code>). Query the users to act for from your own tables (always scoped by <code>tenant_id</code>).</p><p><strong>Dev-mode caveat:</strong> schedule handlers are composed into the runtime at generate time and imported at server startup — editing a handler requires a dev-server restart (unlike routes, they do not hot-reload). Operators can disable all plugin schedules with <code>SOVEREIGN_SCHEDULER_DISABLED=1</code>.</p><h3 id="monetization-—-plugin-monetization-rfc-0003" tabindex="-1"><code>monetization</code> — plugin monetization (RFC 0003) <a class="header-anchor" href="#monetization-—-plugin-monetization-rfc-0003" aria-label="Permalink to "`monetization` — plugin monetization (RFC 0003)""></a></h3><p>Plugins can declare a monetization model to require users to hold a valid signed license before accessing the plugin's routes. Platform plugins (<code>type: "platform"</code>) are always free and may not declare <code>monetization</code>.</p><div class="language-jsonc vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsonc</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"monetization"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "model"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"recurring"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// "free" | "one_time" | "recurring" | "pay_what_you_want"</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "interval"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"month"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// required when model is "recurring"</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "tiers"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [ </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// optional — omit for single-price plugins</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"basic"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Basic"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"price"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"amount"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">500</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"currency"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"USD"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"pro"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Pro"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"price"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"amount"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1500</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"currency"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"USD"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ],</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "license"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // Raw 32-byte Ed25519 public key (base64url). The author signs license tokens</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // with the corresponding private key; the platform verifies offline.</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "publicKey"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"<base64url Ed25519 public key>"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><strong>Monetization models:</strong></p><table tabindex="0"><thead><tr><th>Model</th><th>Description</th></tr></thead><tbody><tr><td><code>free</code></td><td>Default — no entitlement required. Equivalent to omitting the field.</td></tr><tr><td><code>one_time</code></td><td>Single payment grants perpetual access.</td></tr><tr><td><code>recurring</code></td><td>Active subscription required (billed every <code>interval</code>).</td></tr><tr><td><code>pay_what_you_want</code></td><td>User-chosen amount ≥ optional floor; grants access like <code>one_time</code>.</td></tr></tbody></table><p><strong>How it works:</strong> the runtime middleware checks for an active entitlement before serving the plugin's <code>routePrefix</code>. If no valid license exists, the user is redirected to the platform paywall page (<code>/paywall/<pluginId></code>), which shows the tiers and prices and lets the user import a license token. API routes under the prefix return <code>402 Payment Required</code>.</p><p><strong>License tokens</strong> are issued by the plugin author's billing system on confirmed payment. The token format is <code><base64url(JSON payload)>.<base64url(Ed25519 signature)></code>. Verification happens <strong>offline</strong> against the public key in the manifest — no call to any Sovereign service or author service is needed. For recurring licenses, <code>expiresAt</code> is set in the payload and renewal issues a new token.</p><p><strong>Manual / bank transfer flow:</strong> the author confirms payment out of band and sends the token directly to the user, who imports it via the paywall page or <strong>Account → Billing</strong>.</p><p><strong>In-plugin tier gating:</strong> if your plugin has tiers and you want to gate specific features by tier, use <code>sdk.billing.getEntitlement()</code> inside server components. The route-level access check (entitlement exists + not expired) is done automatically by the middleware — <code>requireEntitlement()</code> is only needed for fine-grained in-plugin checks.</p><blockquote><p><strong><code>sdk.billing</code> is reserved</strong> — the stub is in place but the live implementation ships in a future release. <code>sdk.billing.getEntitlement()</code> and <code>sdk.billing.requireEntitlement()</code> throw <code>NotImplementedError</code> until then. Route-level access (middleware gating) is fully functional now.</p></blockquote><h4 id="testing-monetization-locally" tabindex="-1">Testing monetization locally <a class="header-anchor" href="#testing-monetization-locally" aria-label="Permalink to "Testing monetization locally""></a></h4><p>The <code>example-plugins/example-monetized</code> plugin ships with a committed demo keypair and a pre-signed token so you can walk through the full flow without any billing setup. Set <code>SOVEREIGN_EXAMPLES_ENABLED=1</code> in your <code>.env</code> (see <a href="#example-plugins">Example plugins</a> above), start the dev server, and go to <code>/example-monetized</code> — on first visit you will be redirected to the paywall page because you have no entitlement yet.</p><p><strong>Step 1 — Generate a keypair</strong> (once per plugin; keep the private key secret):</p><blockquote><p><strong>Tip — browser-based generator:</strong> If you're the operator of a self-hosted instance, <strong>Console → Entitlements → Generate license token</strong> lets you generate a keypair in-browser, save both keys to instance storage in one click, and sign tokens immediately — no Node.js required and no manifest update needed (see <a href="#key-rotation">Key rotation</a> below).</p></blockquote><p>Otherwise, generate via Node:</p><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">node</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> -e</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> "</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const c = require('crypto');</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const { publicKey: pub, privateKey: priv } = c.generateKeyPairSync('ed25519');</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const { x } = pub.export({ format: 'jwk' });</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const { d } = priv.export({ format: 'jwk' });</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">console.log('Public key (put in manifest):', x);</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">console.log('Private key (keep in secret): ', d);</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"</span></span></code></pre></div><p>Put the public key (<code>x</code>) in <code>manifest.json → monetization.license.publicKey</code>. Store the private key in your billing backend — never commit it.</p><p><strong>Step 2 — Declare monetization in your manifest:</strong></p><div class="language-jsonc vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsonc</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"monetization"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "model"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"recurring"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "interval"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"month"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "tiers"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"pro"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Pro"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"price"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"amount"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1500</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"currency"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"USD"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ],</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "license"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "publicKey"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"<your base64url Ed25519 public key>"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Run <code>pnpm generate</code> after editing the manifest. Visiting the plugin's route now redirects to the paywall.</p><p><strong>Step 3 — Sign a license token</strong> (your billing backend does this after payment; for local testing you can run it manually):</p><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">node</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> -e</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> "</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const c = require('crypto');</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const priv = c.createPrivateKey({</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> key: {</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> kty: 'OKP', crv: 'Ed25519',</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> x: '<YOUR_PUBLIC_KEY>',</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> d: '<YOUR_PRIVATE_KEY>'</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> },</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> format: 'jwk'</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">});</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const payload = Buffer.from(JSON.stringify({</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> pluginId: 'your.plugin.id',</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sub: 'user@example.com',</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> issuedAt: Math.floor(Date.now() / 1000),</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> tier: 'pro'</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">})).toString('base64url');</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">const sig = c.sign(null, Buffer.from(payload), priv).toString('base64url');</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">console.log(payload + '.' + sig);</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"</span></span></code></pre></div><p>The output is the token to deliver to the user.</p><p><strong>Step 4 — Import the token:</strong></p><p>The user pastes the token on the paywall page (<code>/paywall/<pluginId></code>) and clicks <strong>Activate license</strong> — or imports it later in <strong>Account → Billing</strong>. The platform verifies the Ed25519 signature offline and grants immediate access.</p><p><strong>Step 5 — Test error paths:</strong></p><table tabindex="0"><thead><tr><th>Scenario</th><th>How to reproduce</th><th>Expected result</th></tr></thead><tbody><tr><td>No entitlement</td><td>Visit the plugin route without importing a token</td><td>303 redirect to <code>/paywall/<pluginId></code></td></tr><tr><td>API route, no entitlement</td><td><code>curl /api/<slug>/anything</code></td><td><code>402 Payment Required</code></td></tr><tr><td>Expired token</td><td>Set <code>expiresAt</code> in the past and sign</td><td>"License has expired" on the paywall form</td></tr><tr><td>Wrong plugin</td><td>Use a token signed with a different <code>pluginId</code></td><td>"License is for plugin X, not Y"</td></tr><tr><td>Tampered token</td><td>Flip a character in the signature half</td><td>"Signature verification failed."</td></tr><tr><td>Cancelled entitlement</td><td>Cancel in Account → Billing, then revisit</td><td>Redirected to paywall</td></tr></tbody></table><h4 id="key-rotation" tabindex="-1">Key rotation <a class="header-anchor" href="#key-rotation" aria-label="Permalink to "Key rotation""></a></h4><p>You can rotate the signing keypair after deployment without rebuilding the image.</p><p>The platform resolves the public key for token verification in this order:</p><ol><li><strong>Instance storage</strong> (<code>platform_settings</code> key <code>license_public_key:<pluginId></code>) — written when an operator saves a keypair via Console → Entitlements → Generate license token. Takes precedence over the manifest.</li><li><strong>Manifest</strong> (<code>monetization.license.publicKey</code>) — the build-time default, used for third-party plugins where the operator never holds the private key.</li></ol><p><strong>To rotate via the Console (no redeploy):</strong></p><ol><li>Open Console → Entitlements → Generate license token.</li><li>Click <strong>Generate new keypair</strong> — browser generates a fresh Ed25519 pair.</li><li>Click <strong>Save to instance</strong> — both the private key (<code>d</code>) and public key (<code>x</code>) are stored in <code>platform_settings</code>. Existing tokens signed with the old key will immediately fail; issue new tokens to existing subscribers before rotating in production.</li><li>Done. New tokens verify against the stored key.</li></ol><p>The manifest value is not required to change. If you do update it (e.g. when publishing a new plugin version), it has no effect while an instance-stored key is present.</p><p><strong>Token payload reference:</strong></p><table tabindex="0"><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td><code>pluginId</code></td><td>string</td><td>yes</td><td>Must match the manifest <code>id</code> exactly</td></tr><tr><td><code>sub</code></td><td>string</td><td>yes</td><td>Subscriber identity (email or instance domain)</td></tr><tr><td><code>issuedAt</code></td><td>number</td><td>yes</td><td>Unix epoch seconds</td></tr><tr><td><code>expiresAt</code></td><td>number</td><td>no</td><td>Unix epoch seconds. Omit for perpetual licenses.</td></tr><tr><td><code>tier</code></td><td>string</td><td>no</td><td>Tier ID (e.g. <code>"pro"</code>). Omit for single-tier plugins.</td></tr></tbody></table><h3 id="example-manifest-json" tabindex="-1">Example <code>manifest.json</code> <a class="header-anchor" href="#example-manifest-json" aria-label="Permalink to "Example `manifest.json`""></a></h3><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "schemaVersion"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"io.example.tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "version"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"0.1.0"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"A minimal, privacy-first task manager."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "type"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"sovereign"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "runtime"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"native"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "routePrefix"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"/tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "shell"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"default"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "icon"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"icon.svg"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "permissions"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"auth:session"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"db:readWrite"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">],</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "repository"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"https://github.com/sovereignfs/sovereign-plugin-tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "compatibility"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"minPlatformVersion"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"0.5.0"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><h2 id="using-the-sdk" tabindex="-1">Using the SDK <a class="header-anchor" href="#using-the-sdk" aria-label="Permalink to "Using the SDK""></a></h2><p>Import everything platform-related from <code>@sovereignfs/sdk</code>:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span></code></pre></div><p>The SDK surface (<code>sdk.*</code>):</p><ul><li><p><strong><code>auth</code></strong> — session and account.</p><ul><li><code>getSession()</code> → <code>Session | null</code>; <code>requireSession()</code> → <code>Session</code> (throws <code>NotAuthenticatedError</code> if unauthenticated).</li><li><code>hasCapability(session, capability)</code> → <code>boolean</code> — checks whether the session holds a given platform capability (RFC 0021). Use this instead of comparing <code>user.role</code> directly; the platform may change role-to-capability mappings without changing the role names.</li><li><code>changePassword({ currentPassword, newPassword })</code>, <code>listSessions()</code>, <code>revokeSession(token)</code>, <code>signOut()</code> (ends the current session; the caller redirects afterwards).</li></ul><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> session</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.auth.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">requireSession</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// session.user: { id, email, name, image, role, tenantId, capabilities }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">user</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> session;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Prefer capability checks over role comparison:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (sdk.auth.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">hasCapability</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(session, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'user:manage'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">)) {</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // current user can manage other users</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div></li><li><p><strong><code>db</code></strong> — <code>getClient()</code> returns the platform Drizzle client (await it — the data layer is dialect-agnostic and async). Query your own slug-prefixed tables with it (see Database).</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> db</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getClient</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span></code></pre></div></li><li><p><strong><code>mailer</code></strong> — <code>send({ to, subject, text, html }, requestHeaders?)</code>. Requires <code>mailer:send</code> and <code>mailer:sendExternal</code> (RFC 0062 — see "Plugin email" above); pass <code>await headers()</code> as <code>requestHeaders</code>. No-ops when SMTP is unconfigured.</p></li><li><p><strong><code>email</code></strong> — <code>sendToUser({ recipientUserId, templateId, subject, html?, text?, data? }, requestHeaders?)</code> (RFC 0062). The recommended default over <code>mailer.send</code> — see "Plugin email" above. Requires <code>mailer:send</code>; pass <code>await headers()</code> as <code>requestHeaders</code>.</p></li><li><p><strong><code>platform</code></strong> — <code>getConfig()</code> → <code>{ tenantName, inviteOnly, version, instanceName, instancePrimaryColor? }</code> (await it). <code>instanceName</code> falls back to <code>tenantName</code> when no instance name is configured; <code>instancePrimaryColor</code> is a validated 6-digit hex string or <code>undefined</code>. Use these to display the operator's instance identity in plugin UI without reading CSS variables.</p></li><li><p><strong><code>directory</code></strong> — member selection for sharing, assignment, membership, and recipient flows (RFC 0041). No manifest permission is required. Use <code>searchUsers({ query, limit? })</code> for user-picker search and <code>resolveUsers({ ids })</code> to refresh profile labels for IDs already stored in your plugin tables. Both methods return only active users in the current tenant and only display-safe fields: <code>{ id, email, name, image }</code>.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> matches</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.directory.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">searchUsers</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ query: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'kas'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, limit: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> });</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> selected</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.directory.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">resolveUsers</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ ids: memberUserIds });</span></span></code></pre></div><p>Queries must be at least two characters and are capped to 20 results by default, 50 maximum. Do not call Console/admin user routes from plugins; store selected user IDs in your own membership/share table and resolve them through this SDK surface when rendering.</p></li><li><p><strong><code>secrets</code></strong> — encrypted runtime-created plugin secrets (RFC 0043). Use <code>create/get/list/update/delete</code> for OAuth tokens, PATs, webhook secrets, and other values created after deployment. <code>list</code> returns metadata only; exports never include plaintext values. Use plugin-scoped env vars for operator-supplied deployment secrets instead.</p></li><li><p><strong><code>data</code></strong> — cross-plugin data sharing (RFC 0002). <code>sdk.data.provide(contract, resolver)</code> registers a resolver; <code>sdk.data.query(ref, params)</code> reads from another plugin's contract (throws <code>ConsentRequiredError</code> without a user grant). See the <a href="#data--cross-plugin-data-sharing-rfc-0002"><code>data</code> manifest field section</a> above.</p></li><li><p><strong><code>activity</code></strong> — <code>sdk.activity.log(entry)</code> records a scoped audit event (RFC 0005). The runtime injects the actor identity, tenant, and plugin so a plugin cannot forge actor context. Plugin-sourced events are always <code>user</code>-scoped (visible to the acting user in their Activity feed, and to admins in the Console feed). Requires <code>activity:write</code> in the manifest.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.activity.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">log</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> action: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'list.created'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> targetType: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'list'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> targetId: newList.id,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> summary: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">`Created list "${</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">newList</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">.</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">title</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">}"`</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadata: { title: newList.title },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div></li><li><p><strong><code>portability</code></strong> — participate in user-initiated data export/import (RFC 0007, RFC 0052). Register an export resolver (<code>sdk.portability.provideExport(resolver)</code>) and/or an import handler (<code>sdk.portability.provideImport(handler)</code>) from a server-side handler (Server Component, Route Handler, or Server Action). The resolver receives an <code>ExportContext { userId, tenantId, options: { includeFiles } }</code> — respect <code>options.includeFiles</code> when deciding whether to attach large blobs — and must return a <code>PluginExportSection { pluginId, pluginVersion?, schemaVersion, data, blobs?, secretMetadata?, warnings? }</code>. <code>pluginVersion</code> is optional; the runtime always overwrites it with your plugin's installed manifest version, so you can't misreport it. <code>secretMetadata</code> lists metadata for secrets your plugin owns (<code>{ label, provider, exists }</code>) — <strong>never</strong> include plaintext secret values anywhere in the export. <code>warnings</code> surfaces non-fatal notices (e.g. a file that was skipped) in the bundle manifest. If your resolver throws, the plugin is excluded from the bundle and recorded in the manifest's <code>failures</code> list — it does not abort the rest of the user's export. The import handler receives the stored section plus an <code>ImportContext { userId, tenantId, remapId(originalId) }</code> — use <code>remapId</code> to translate stored IDs to fresh ones for the importing account (referential integrity). Declare <code>data:export</code> and/or <code>data:import</code> in the manifest; the runtime skips unregistered or un-permitted plugins silently.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Server Component or Route Handler in your plugin:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.portability.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">provideExport</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">userId</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">options</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> pluginId: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'io.example.tasks'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> schemaVersion: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> data: { tasks: </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> myDb.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getTasksForUser</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(userId) },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> blobs: options.includeFiles </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">?</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> myDb.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getAttachmentsForUser</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(userId) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> undefined</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> secretMetadata: </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> myDb.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">listSecretMetadataForUser</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(userId),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.portability.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">provideImport</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">section</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">userId</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">remapId</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">tasks</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> section.data </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">as</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">tasks</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">id</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> string</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">; </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">title</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> string</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }[] };</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> task</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> of</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> tasks) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> myDb.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">createTask</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ id: </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">remapId</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(task.id), userId, title: task.title });</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p><strong>Account deletion (RFC 0033):</strong> register a deletion handler via <code>sdk.portability.provideDelete(handler)</code> to clean up your plugin's data when a user account is deleted (self-service or admin-initiated). The handler receives <code>DeletionContext { userId, tenantId, db }</code> where <code>db</code> is your plugin's Drizzle client. Return <code>{ deleted: number; errors?: string[] }</code>. Plugins without a handler have their rows left in place — document this in your plugin's README. The runtime runs all handlers in parallel with a 30 s timeout each.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.portability.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">provideDelete</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">userId</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">db</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> result</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (db </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">as</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> MyDb</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">delete</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(myTasks).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">where</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">eq</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(myTasks.userId, userId));</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { deleted: result.rowsAffected </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">??</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> };</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p><strong>Cross-plugin references in exports (RFC 0051):</strong> a <code>PluginExportSection</code> may also include <code>references?: PluginReference[]</code> — opaque links your plugin holds to another plugin's records (see <code>sdk.plugins</code> below for the shape). These are carried as <strong>inert metadata only</strong>: the platform never dereferences them on export or import, and importing a reference never grants access to the provider plugin.</p><p><strong>Export completeness (RFC 0068):</strong> <code>manifest.json</code>'s <code>installedPlugins</code> lists every plugin installed for the exporting user's tenant — regardless of export participation — each flagged with <code>enabled</code>, <code>participatesExport</code>, and <code>participatesImport</code>. A plugin that declares <code>data:export</code> and is enabled but has no registered exporter (e.g. it declared the permission without ever calling <code>provideExport</code>) is recorded in <code>notExported</code> with a reason (<code>no-export-hook</code> or <code>disabled</code>) instead of being silently absent from the bundle — the Account Data tab surfaces this list by name so a user can tell "no data" from "this app doesn't support export yet." <strong>Declare <code>data:export</code>/<code>data:import</code> only once you've actually registered the matching hook</strong> — an unearned permission declaration is exactly the gap this closes. Export is synchronous and capped at 50MB (<code>MAX_EXPORT_BYTES</code> in <code>runtime/app/api/account/export/route.ts</code>) — a bundle that would exceed the cap returns a clear error rather than a truncated or unimportable ZIP; keep large attachments behind <code>options.includeFiles</code> so a user can retry with a smaller export.</p></li><li><p><strong><code>plugins</code></strong> — dependency discovery and cross-plugin references (RFC 0051). <code>sdk.plugins.get(id)</code> / <code>sdk.plugins.list(filter?)</code> return <code>PluginAvailability { id, name, routePrefix, icon?, installed, enabled, availableToUser, providesContracts }</code> for installed plugins — <code>availableToUser</code> folds in disabled/adminOnly/paywall status for the <em>current</em> user (<code>false</code> outside an authenticated request). Use this before offering an integration with an optional sibling plugin — declare it in the manifest <code>integrations.optional</code> field (see above) for install/discovery UX. <code>sdk.plugins.getConsentStatus(ref)</code> checks whether the current user has granted your plugin's <code>data.consumes</code> contract without doing a full <code>sdk.data.query()</code> call. Also exports <code>PluginReference { providerId, resourceType, resourceId, contract?, version?, labelSnapshot?, metadata?, linkedAt }</code> — the standard shape for storing an opaque pointer to another plugin's record in your own tables. <code>resourceId</code> is opaque to you; treat a stored reference as a nullable link and handle the provider being uninstalled, disabled, revoked, or the resource deleted — a live dereference always goes through <code>sdk.data.query()</code> and current consent, never the reference alone.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> crm</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.plugins.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'io.example.crm'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (crm?.availableToUser) {</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // offer "Link to contact" — store a PluginReference pointing at the CRM record</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div></li><li><p><strong><code>env</code></strong> — plugin-scoped environment variables (RFC 0018). <code>sdk.env.get(key)</code> reads the calling plugin's <code>SV_PLUGIN_<SLUG>_<KEY></code> env var, identified by the <code>x-sovereign-plugin-id</code> request header. Returns <code>null</code> when absent or called outside a plugin route. Declare vars in the manifest <code>env</code> field (see above). Server-side only (uses <code>next/headers</code>).</p></li><li><p><strong><code>connections</code></strong> — external provider connection metadata (RFC 0049). <code>sdk.connections.create/list/get/update/disconnect/markUsed/markError</code> manages platform-owned metadata rows for the calling plugin; all credential values stay in <code>sdk.secrets</code>. <code>createOAuthState</code> and <code>verifyOAuthState</code> provide signed OAuth callback state helpers. <code>getProviderConfig(provider)</code> returns the calling plugin's effective server-side provider config, merging plugin-scoped runtime env vars with Console-managed config where Console values take precedence.</p></li><li><p><strong><code>notifications</code></strong> — Notification Center (RFC 0015). <code>sdk.notifications.send(input, requestHeaders)</code> delivers a notification to a user's inbox. Requires the <code>notifications:send</code> manifest permission. The runtime injects <code>source</code> (plugin ID) and <code>sourceType</code> automatically — plugins supply <code>recipientUserId</code>, <code>title</code>, and optionally <code>body</code>, <code>url</code>, <code>category</code>, and <code>icon</code>. Users can mute categories (except <code>security</code>) in their Account Notifications tab. See <a href="#notifications-rfc-0015">notifications (RFC 0015)</a> below.</p></li><li><p><strong><code>billing</code></strong> — plugin monetization / entitlement gating (RFC 0003). <code>sdk.billing.getEntitlement(headers)</code> returns the current user's active entitlement for the calling plugin (tier + expiry), or <code>null</code> if none exists. <code>sdk.billing.requireEntitlement(headers)</code> throws <code>EntitlementRequiredError</code> when absent. Route-level access is gated automatically by the middleware — these helpers are only needed for <strong>in-plugin feature gating by tier</strong>. See <a href="#monetization--plugin-monetization-rfc-0003"><code>monetization</code> manifest field</a> above.</p><blockquote><p><strong>Reserved</strong> — stubs are in place; the live implementation ships in a future release. Both methods throw <code>NotImplementedError</code> until then.</p></blockquote></li><li><p><strong><code>storage</code></strong> — plugin-scoped binary object storage (RFC 0044). Requires the <code>storage:readWrite</code> manifest permission. See <a href="#plugin-file-storage-rfc-0044">Plugin file storage (RFC 0044)</a> below.</p></li><li><p><strong>Reserved</strong> (throw <code>NotImplementedError</code> in v1): <code>events</code>.</p></li></ul><h3 id="the-sdk-boundary-rule" tabindex="-1">The SDK boundary rule <a class="header-anchor" href="#the-sdk-boundary-rule" aria-label="Permalink to "The SDK boundary rule""></a></h3><p>Plugins <strong>must not</strong> import from <code>runtime/src</code> or internal <code>@sovereignfs/*</code> packages (<code>db</code>, <code>manifest</code>, <code>mailer</code>) directly — only <code>@sovereignfs/sdk</code> and <code>@sovereignfs/ui</code>. ESLint enforces this; violations fail <code>pnpm lint</code>.</p><h3 id="ui" tabindex="-1">UI <a class="header-anchor" href="#ui" aria-label="Permalink to "UI""></a></h3><p>Build your interface with the Sovereign Design System (<code>@sovereignfs/ui</code>):</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { Button, Input, Textarea, Dialog, Drawer, Icon } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span></code></pre></div><p>Design tokens (<code>--sv-*</code> CSS custom properties) are injected globally by the runtime shell — reference them directly in your CSS, e.g. <code>color: var(--sv-color-text-primary)</code>. See <a href="./design-system.html">design-system.md</a>.</p><h4 id="building-forms" tabindex="-1">Building forms <a class="header-anchor" href="#building-forms" aria-label="Permalink to "Building forms""></a></h4><p>Wrap a labeled control in <code>FormField</code> rather than hand-rolling a <code><label htmlFor></code> + hint/error paragraph. Its <code>children</code> is a render prop — it receives the props (<code>id</code>, <code>aria-describedby</code>, <code>aria-invalid</code>, <code>required</code>) that must be spread onto the control so the label, hint, and error stay correctly associated:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { Button, FormField, Input, Select, Textarea } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Email"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> hint</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Used for sign-in"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> required</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">field</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Input</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">field} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">type</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"email"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> />}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Role"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> error</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{errors.role}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">field</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Select</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">field}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">option</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> value</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"member"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>Member</</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">option</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">option</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> value</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"admin"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>Admin</</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">option</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> </</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Select</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> )}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Notes"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">field</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Textarea</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">field} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">rows</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">4</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">} />}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span></code></pre></div><p><code>id</code> is generated automatically via <code>useId()</code> if you don't pass one. <code>field</code> works with any control — <code>Input</code>, <code>Select</code>, <code>Textarea</code>, or a native element — as long as it forwards <code>id</code>/<code>aria-*</code> to the underlying form element.</p><h4 id="editor-workflow-primitives" tabindex="-1">Editor workflow primitives <a class="header-anchor" href="#editor-workflow-primitives" aria-label="Permalink to "Editor workflow primitives""></a></h4><p>Content and data-entry plugins should use the editor primitives before adding plugin-local generic control CSS:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> CodeTextarea,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> FormField,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> SplitPane,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> StatusBadge,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> TagInput,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">} </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">StatusBadge</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> status</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"draft"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>Draft</</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">StatusBadge</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">SplitPane</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> primary</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{<</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">CodeTextarea</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> aria-label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Markdown source"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> defaultValue</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{source} />}</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> secondary</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{<</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">article</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>{preview}</</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">article</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">/></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Tags"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> hint</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Press Enter or comma to add a tag."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">field</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">TagInput</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">field} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">value</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{tags} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">onChange</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{setTags} />}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Raw frontmatter"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> error</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{yamlError}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">field</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">CodeTextarea</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">field} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">invalid</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">Boolean</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(yamlError)} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">defaultValue</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{yaml} />}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">FormField</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span></code></pre></div><p>Use <code>StatusBadge</code> for file sync and lifecycle states such as draft, synced, conflict, pending delete, warning, and error. Use <code>SplitPane</code> for editor/preview or list/detail layouts instead of hand-rolled resizable panes; it stacks to one column on narrow screens and keeps the separator keyboard-operable. Use <code>TagInput</code> for frontmatter arrays and lightweight labels; it handles Enter, comma, Backspace, paste splitting, duplicate rejection, and validation messages. Use <code>CodeTextarea</code> for Markdown/YAML/JSON where whitespace and monospace rendering matter.</p><h4 id="when-to-reach-for-a-primitive-vs-local-css" tabindex="-1">When to reach for a primitive vs. local CSS <a class="header-anchor" href="#when-to-reach-for-a-primitive-vs-local-css" aria-label="Permalink to "When to reach for a primitive vs. local CSS""></a></h4><p>Use a <code>@sovereignfs/ui</code> primitive (<code>Button</code>, <code>Input</code>, <code>Select</code>, <code>Textarea</code>, <code>CodeTextarea</code>, <code>TagInput</code>, <code>Checkbox</code>, <code>FormField</code>, <code>Card</code>, <code>Badge</code>, <code>StatusBadge</code>, <code>SplitPane</code>, <code>PageHeader</code>, <code>PageContainer</code>, <code>SystemBanner</code>, …) for any generic control or page-structure pattern — anything another plugin, or the platform shell, would plausibly need too. Keep CSS local for layout that is genuinely specific to your plugin's domain (a custom data table, a graph, a canvas) — the design system does not try to cover every possible layout, only the repeated primitives.</p><p>A short "do not" list:</p><ul><li>Don't reference primitive colour tokens (<code>--sv-grey-*</code>, <code>--sv-red-*</code>, …) — use semantic tokens (<code>--sv-color-*</code>) only.</li><li>Don't hardcode hex/<code>rgb()</code> colours in your CSS — use a token, or a status token (<code>--sv-color-error-text</code>, etc.) if none fits.</li><li>Don't remove the focus ring (<code>outline: none</code>) without providing an equivalent <code>:focus-visible</code> treatment.</li><li>Don't nest a <code>Card</code> inside another <code>Card</code> — pick one surface per visual group.</li><li>Don't ship an icon-only control without an accessible name (<code>aria-label</code> on the control, or a visually-hidden label).</li></ul><h4 id="page-layout" tabindex="-1">Page layout <a class="header-anchor" href="#page-layout" aria-label="Permalink to "Page layout""></a></h4><p>The runtime shell already pads your plugin's main content (32px desktop / 16px mobile) — don't add your own outer <code>padding</code> or <code>max-width</code> in your <code>app/layout.tsx</code> or page CSS. If you want to additionally constrain content to a readable width, wrap it in <code>PageContainer</code> instead of local container CSS:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { PageContainer } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> default</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Layout</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">children</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">children</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> React</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">ReactNode</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">PageContainer</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> maxWidth</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"md"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>{children}</</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">PageContainer</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">>;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>See <code>docs/design-system.md</code>'s <a href="./design-system.html#page-layout--pagecontainer">"Page layout"</a> section for the full <code>maxWidth</code> scale and the <code>data-plugin-fullbleed</code> opt-out for plugins that manage their own full-bleed layout.</p><h4 id="using-icons" tabindex="-1">Using icons <a class="header-anchor" href="#using-icons" aria-label="Permalink to "Using icons""></a></h4><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { Icon } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Decorative (described by surrounding text — hide from screen readers)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Icon</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> name</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"trash-2"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> size</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"md"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> aria-hidden</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> /></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Meaningful (standalone — add a screen-reader label)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Icon</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> name</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"log-out"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> aria-label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Sign out"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> /></span></span></code></pre></div><p>Available sizes: <code>"sm"</code> (16px), <code>"md"</code> (20px, default), <code>"lg"</code> (24px). Color follows <code>currentColor</code> automatically — icons inherit the surrounding text color and recolor with theme changes.</p><p>The full icon list is in <code>scripts/icon-list.ts</code>. To request a new icon for the platform set, open an issue; to use an icon not in the set today, copy the SVG inline in your plugin (Lucide icons are ISC-licensed).</p><h4 id="plugin-identity-icons-vs-ui-affordance-icons" tabindex="-1">Plugin-identity icons vs UI-affordance icons <a class="header-anchor" href="#plugin-identity-icons-vs-ui-affordance-icons" aria-label="Permalink to "Plugin-identity icons vs UI-affordance icons""></a></h4><p>Your plugin's <code>icon.svg</code> (the <code>icon</code> manifest field) is your plugin's <strong>identity</strong> — it appears in the Launcher tile and the sidebar. It is rendered as <code><img src="/plugin-icons/<id>.svg" alt=""></code> by the platform, never as raw SVG, so arbitrary SVG features (scripts, foreignObject) are inert.</p><p>For UI-affordance icons <em>within</em> your plugin UI, use <code><Icon name="…"></code> from <code>@sovereignfs/ui</code>. Do <strong>not</strong> use <code>dangerouslySetInnerHTML</code> to inject third-party SVG content — this is an XSS vector.</p><p><strong>Guidance for your <code>icon.svg</code>:</strong> draw a <code>24×24</code> <code>viewBox="0 0 24 24"</code> stroke icon with <code>fill="none" stroke="currentColor" stroke-width="2"</code> so your icon sits visually with the Lucide-based platform icons. The monogram (first two initials of your plugin name) is shown as a fallback when no <code>icon.svg</code> is present.</p><h2 id="database" tabindex="-1">Database <a class="header-anchor" href="#database" aria-label="Permalink to "Database""></a></h2><p>Plugins access the database through <code>await sdk.db.getClient()</code>. There are two isolation modes, set in the manifest:</p><table tabindex="0"><thead><tr><th></th><th><code>shared</code> (default)</th><th><code>isolated</code></th></tr></thead><tbody><tr><td>Store</td><td>Platform DB (shared with all plugins)</td><td>Dedicated file or schema per plugin</td></tr><tr><td>Table prefix</td><td>Required (slug, e.g. <code>tasks_lists</code>)</td><td>Not required</td></tr><tr><td>Uninstall</td><td>Tables remain</td><td>Entire store dropped</td></tr><tr><td>Migrations</td><td><code>plugins/<id>/migrations/</code> against platform DB</td><td>Same path, routed to dedicated store</td></tr></tbody></table><p><strong>For most plugins, shared is the right choice.</strong> Use <code>"database": "isolated"</code> when you need a clean data lifecycle (e.g. uninstall should delete all plugin data), per-plugin backup, or blast-radius isolation.</p><p>An isolated plugin can also request SQLite explicitly:</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "database"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "isolation"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"isolated"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "dialect"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"sqlite"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Omitting <code>dialect</code> inherits the platform database dialect. <code>dialect: "postgres"</code> is not a valid manifest value; an isolated plugin gets Postgres by inheriting it from a Postgres platform.</p><p>An isolated plugin can additionally require SQLite at-rest encryption (RFC 0071) for its own store:</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "database"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "isolation"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"isolated"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "dialect"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"sqlite"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "requireEncryption"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><code>requireEncryption</code> is <strong>raise-only</strong>: it forces encryption on for this plugin's isolated database regardless of the instance-wide <code>SOVEREIGN_DB_ENCRYPTION_KEY</code> default, but a plugin can never use it to opt <em>out</em> of encryption the operator has enabled. It requires <code>isolation: "isolated"</code> — whole-file encryption has no per-table granularity, so a <code>shared</code> plugin (whose tables live inside the platform database) cannot independently demand it; the manifest fails validation otherwise. It also <strong>requires an explicit <code>dialect: "sqlite"</code></strong> alongside it — omitting <code>dialect</code> would let the platform's own dialect choice silently decide whether this is actually enforced, since there is no SQLCipher equivalent for Postgres: a plugin resolved to Postgres only gets a startup warning and a fallback to disk-level encryption for its store, not a real guarantee. Pinning <code>dialect: "sqlite"</code> explicitly means the manifest alone determines the outcome — the runtime refuses to start rather than silently install the plugin without the encryption it declares, instead of that guarantee quietly depending on what the operator chose for the platform database — see <a href="./self-hosting.html#sqlite-at-rest-encryption-rfc-0071">docs/self-hosting.md's SQLite at-rest encryption section</a>.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Same call regardless of mode — the runtime routes transparently:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> db</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getClient</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span></code></pre></div><p><strong>Rules that apply in both modes:</strong></p><ul><li><code>tenant_id</code> on every user-scoped table (multi-tenancy readiness).</li><li>Your <code>schema.ts</code> can target one dialect (typically <code>sqlite-core</code>) — Drizzle's query builder is bound to the client connection, not the table object, so it works against Postgres too, <strong>but only if Postgres columns serialize identically</strong> (plain <code>integer</code> for booleans/timestamps, never native <code>boolean</code>/<code>bigint</code>). You still need a separate <code>pgTable</code>-based schema file to generate Postgres migrations — <code>drizzle-kit</code> cannot read a <code>sqliteTable()</code> schema for that. See <code>docs/plugin-database.md</code> for the full pattern.</li></ul><p>See <strong><a href="./../docs/plugin-database.html"><code>docs/plugin-database.md</code></a></strong> for the full reference: shared conventions, isolated provisioning details (SQLite file path, Postgres schema naming), migration setup, lifecycle (provision / uninstall / <code>--keep-data</code>), and backup.</p><h2 id="plugin-file-storage-rfc-0044" tabindex="-1">Plugin file storage (RFC 0044) <a class="header-anchor" href="#plugin-file-storage-rfc-0044" aria-label="Permalink to "Plugin file storage (RFC 0044)""></a></h2><p><code>sdk.storage</code> gives a plugin a scoped place to put binary objects — attachments, generated documents, imports/exports, thumbnails, media captured from the browser — without inventing ad hoc paths. Requires the <code>storage:readWrite</code> manifest permission.</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { sdk } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> object</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">put</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> key: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'receipts/2026-01.pdf'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> body: fileBytes, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Blob | ArrayBuffer | Uint8Array</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> contentType: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'application/pdf'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ownerUserId: session.user.id, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// omit for a plugin-scoped (not per-user) object</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadata: { source: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'import'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> found</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'receipts/2026-01.pdf'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">); </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// StorageObject & { body: ReadableStream } | null</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">found?.metadata; </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// { source: 'import' } — round-tripped back exactly as passed to put()</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">delete</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'receipts/2026-01.pdf'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> all</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">list</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'receipts/'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">); </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// optional key prefix filter</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Short-lived, read-only download URL (default 5 min, max 1 hour):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> url</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">getSignedUrl</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'receipts/2026-01.pdf'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, { expiresInSeconds: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">600</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> });</span></span></code></pre></div><p><strong>Ownership and access.</strong> An object created with <code>ownerUserId</code> is only readable/listable by that user (or, once other users can see it, never — there is no sharing). Omitting <code>ownerUserId</code> makes an object plugin-scoped: any request in that plugin/tenant can read it, which is the right shape for plugin-generated assets nobody "owns" (a shared logo, a generated report template). <code>key</code> is the plugin-facing logical path — the physical filename on disk is always a server-generated opaque ID, so a caller-supplied <code>key</code> can never path-traverse into another object or outside the plugin's storage directory.</p><p><strong>Files are private by default.</strong> <code>sdk.storage.get()</code>/<code>getSignedUrl()</code> are the only ways to read bytes back — there is no public URL construction. Serve a file to the browser either from your own authenticated route handler (call <code>sdk.storage.get()</code> there after your own membership/ownership check) or via <code>getSignedUrl()</code>, which returns a <code>/api/storage/<token></code> URL good for one object until it expires. The token is HMAC-signed and cannot be extended or widened by editing it; the runtime serves it with <code>Cache-Control: private, no-store</code> and does not require a session cookie (so it works from a plain <code><img src></code> or direct download link). Public, permanent file hosting is out of scope for v1 — combine a public plugin route (RFC 0042) with your own authorization check if you need that.</p><p><strong>Backend.</strong> v1 ships a local-filesystem backend only, under <code>data/plugins/<pluginId>/storage/</code> — no S3/CDN configuration is required or possible yet. This is intentionally invisible to plugin code: if the platform later adds an S3-compatible backend, <code>sdk.storage</code> calls do not change.</p><p><strong>Quotas.</strong> Conservative default limits apply per object and per plugin (see <a href="./self-hosting.html"><code>SOVEREIGN_STORAGE_MAX_OBJECT_BYTES</code> / <code>SOVEREIGN_STORAGE_MAX_PLUGIN_BYTES</code></a> in <code>self-hosting.md</code>); <code>sdk.storage.put()</code> throws when either limit would be exceeded. <code>metadata</code> has its own, separate 8 KiB cap, enforced at write time.</p><p><strong>Metadata.</strong> <code>metadata</code> is opaque, plugin-defined JSON — the platform never inspects it. It round-trips unchanged through <code>get()</code>/<code>list()</code>/<code>put()</code>'s return value, which is what makes it the right place for the small, non-sensitive routing fields an encrypted object needs alongside its ciphertext (see <a href="#client-side-encryption-rfc-0060">Client-side encryption</a> below) — there is no separate metadata table or schema to extend.</p><p><strong>Lifecycle.</strong> User-owned storage objects (rows and physical files) are deleted automatically when that user's account is deleted (RFC 0033). Deleting an object via <code>sdk.storage.delete()</code> removes both the metadata row and the physical file immediately.</p><h2 id="client-side-encryption-rfc-0060" tabindex="-1">Client-side encryption (RFC 0060) <a class="header-anchor" href="#client-side-encryption-rfc-0060" aria-label="Permalink to "Client-side encryption (RFC 0060)""></a></h2><p><code>sdk.e2ee</code> (requires the <code>e2ee:use</code> manifest permission) persists client-side encryption metadata — a user's encryption profile, recovery-secret wrapper, and enrolled-device records. It only ever stores <strong>opaque wrapped ciphertext and non-sensitive algorithm/KDF metadata</strong>; the runtime and server-side plugin code never see a plaintext Client Master Key (CMK), Data Encryption Key (DEK), or decrypted object content. All actual cryptography happens in the browser via separate, browser-only subpath exports rather than the main <code>@sovereignfs/sdk</code> barrel — the barrel also reaches server-only modules, and importing it from a <code>'use client'</code> component fails to build:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Key material — CMK generation, master-key wrap/unwrap, per-object DEK wrap/unwrap.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> generateCmk,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> generateDek,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> generateRecoverySecret,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> wrapCmkWithRecoverySecret,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> unwrapCmkWithRecoverySecret,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> wrapDekWithCmk,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> unwrapDekWithCmk,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">} </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk/e2ee-crypto'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// This device's local wrapping key, persisted in IndexedDB.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { getOrCreateDeviceId, storeDeviceKey, getDeviceKey } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk/e2ee-device'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Encrypt/decrypt actual object content under a DEK.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { encryptBlob, decryptBlob, encryptJson, decryptJson } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk/e2ee-object'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Normalized locked/unlocked/not-set-up/unsupported state detection.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { getE2eeLocalState } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/sdk/e2ee-state'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span></code></pre></div><p><strong>Key hierarchy.</strong> Recovery secret or an enrolled device's local key unlocks the CMK; the CMK wraps per-object DEKs; a DEK encrypts one object's binary content (<code>encryptBlob</code>/<code>decryptBlob</code>) and/or its human-readable metadata (<code>encryptJson</code>/<code>decryptJson</code>). Generate a fresh DEK per object — never reuse one across objects — so compromising a single object's key never exposes any other object.</p><p><strong>Setup and unlock</strong> (already built into the Account plugin's Security page — plugins do not need to build their own setup UX): <code>generateCmk()</code> in the browser, wrap it with a user-recorded recovery secret (<code>wrapCmkWithRecoverySecret</code>) and with this device's own key (<code>wrapCmkWithDeviceKey</code>), then persist both wrapped copies via <code>sdk.e2ee.setRecoveryWrapper()</code>/<code>sdk.e2ee.enrollDevice()</code>. A lost device and a lost recovery secret both mean the encrypted data is unrecoverable — there is no operator escrow, by design (the threat model assumes an operator can be compromised).</p><p><strong>Checking state before touching encrypted data:</strong> call <code>getE2eeLocalState(profile, devices)</code> with the profile/enrollments loaded from <code>sdk.e2ee</code>. It returns <code>{ state, deviceId, deviceKey, activeEnrollment }</code> where <code>state</code> is one of <code>'not-set-up' | 'locked' | 'unlocked' | 'unsupported'</code> — plugins must show locked-state UX (not silently fail) when <code>state !== 'unlocked'</code>, and must never attempt to unwrap a CMK/DEK unless <code>state === 'unlocked'</code>.</p><p><strong>Encrypting an object and storing it via <code>sdk.storage</code> (RFC 0060 step 5):</strong> there is no dedicated <code>sdk.e2ee</code> storage method — <code>sdk.storage</code>'s <code>metadata</code> field (opaque, plugin-defined JSON, round-tripped unchanged on <code>get()</code>/<code>list()</code>, see <a href="#plugin-file-storage-rfc-0044">Plugin file storage</a> above) is exactly where the wrapped DEK and algorithm version belong, so the two SDK surfaces compose directly. Encryption happens in the browser; the resulting ciphertext <code>Blob</code> and metadata object are then passed to a server action or route handler that calls <code>sdk.storage</code>, same as any other upload:</p><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// In the browser, once state.state === 'unlocked' (cmk from unwrapCmkWithDeviceKey):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> dek</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> generateDek</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> wrappedDek</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> wrapDekWithCmk</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(dek, cmk);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> encryptedBlob</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> encryptBlob</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(dek, fileBlob);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> encryptedMetadata</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> encryptJson</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(dek, { title, notes });</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Hand `encryptedBlob`/`encryptedMetadata`/`wrappedDek` to your upload action.</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Server-side (route handler / server action) — never sees plaintext:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">put</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> key: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">`documents/${</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">crypto</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">randomUUID</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">()</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">}`</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> body: encryptedBlob.ciphertext, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// opaque ciphertext Blob</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> contentType: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'application/octet-stream'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// never the real content type — that's encrypted too</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ownerUserId: session.user.id,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadata: {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> wrappedDek: wrappedDek.wrappedDek,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> dekAlgorithmVersion: wrappedDek.algorithmVersion,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> blobIv: encryptedBlob.iv,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> blobAlgorithmVersion: encryptedBlob.algorithmVersion,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> encryptedMetadata: encryptedMetadata.ciphertext,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> metadataIv: encryptedMetadata.iv,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Later, server-side: fetch the object (ciphertext + its own wrapped-DEK metadata)...</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> object</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> sdk.storage.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(key);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// ...then back in the browser, unwrap the DEK from that metadata and decrypt:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> objectDek</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> unwrapDekWithCmk</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { wrappedDek: object.metadata.wrappedDek, algorithmVersion: object.metadata.dekAlgorithmVersion },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> cmk,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> fileBlob</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> decryptBlob</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(objectDek, {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ciphertext: </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">await</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Response</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(object.body).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">blob</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> iv: object.metadata.blobIv,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> algorithmVersion: object.metadata.blobAlgorithmVersion,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> contentType: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'application/pdf'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// whatever the plugin actually stored, decided by its own logic</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">});</span></span></code></pre></div><p><strong>Export/delete (RFC 0060 step 6).</strong> The platform handles the core encryption profile/recovery-wrapper/device-enrollment rows itself — they're included in every account export (still wrapped ciphertext only) and removed unconditionally on account deletion, same as everything else <code>sdk.e2ee</code> persists. Your own encrypted objects are a different story: they're plugin data, so they go through your plugin's own <code>sdk.portability</code><code>ExportResolver</code>/<code>ImportHandler</code>/<code>DeletionHandler</code> like any other plugin data — call <code>sdk.storage.list()</code>/<code>get()</code> yourself inside your export resolver to include the ciphertext <code>Blob</code>s and their <code>metadata</code> (wrapped DEK included) in your section's <code>blobs</code>, and <code>sdk.storage.put()</code> inside your import handler to restore them. There is no separate mechanism to learn — <code>sdk.storage</code> and <code>sdk.portability</code> already compose the same way they do for any other file a plugin stores.</p><h2 id="local-development" tabindex="-1">Local development <a class="header-anchor" href="#local-development" aria-label="Permalink to "Local development""></a></h2><p>Run a plugin against a local platform checkout:</p><ol><li><strong>Create it</strong> — scaffold a new plugin skeleton (monorepo context):<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">pnpm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sv</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugin</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> new</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> io.example.my-plugin</span></span></code></pre></div>or install an existing plugin from its repository:<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">pnpm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sv</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugin</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> add</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> https://github.com/you/sovereign-plugin-foo</span></span></code></pre></div>or declare it in <code>sovereign.plugins.json</code> and run <code>pnpm install:plugins</code>. All paths clone into <code>plugins/<id>/</code> and compose it.</li><li><strong>Develop</strong> — <code>pnpm dev</code> starts the runtime (<code>:3000</code>) and auth (<code>:3001</code>) by default, or the <code>RUNTIME_PORT</code> / <code>AUTH_PORT</code> values from the root <code>.env</code>. Edits under <code>plugins/<id>/app/</code> are re-composed and hot-reloaded automatically.</li><li><strong>Remove it</strong> — <code>pnpm sv plugin remove <id></code> (deletes the directory and re-composes; built-in platform plugins are protected).</li></ol><p>Never edit the composed copies under <code>runtime/app/(platform)/(plugins)/</code> — they are generated and git-ignored. Your <code>plugins/<id>/</code> directory is the source of truth.</p><h3 id="developing-a-sovereign-plugin-inside-the-platform-monorepo" tabindex="-1">Developing a sovereign plugin inside the platform monorepo <a class="header-anchor" href="#developing-a-sovereign-plugin-inside-the-platform-monorepo" aria-label="Permalink to "Developing a sovereign plugin inside the platform monorepo""></a></h3><p>If you are building a <code>type: sovereign</code> plugin — one that lives in its own repository but you want to test against a local platform checkout before publishing — clone it under <code>plugins/</code> with a <code>.local</code> suffix:</p><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">git</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> clone</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> git@github.com:yourorg/your-plugin.git</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugins/your-plugin.local</span></span></code></pre></div><blockquote><p><strong>Tip — automate this from the workbench.</strong> Rather than cloning each plugin by hand, the <code>sovereignfs/sovereignfs</code> workbench repo's <code>workbench plugins pull</code> command reads a personal, git-ignored <code>sovereign.plugins.local</code> list at its own root and clones every listed repo into this checkout's <code>plugins/<name>.local</code> (skipping ones already present). See that repo's <code>AGENTS.md</code> for details — this repo has no equivalent script of its own.</p></blockquote><p>The <code>.local</code> suffix is the project convention for this pattern. It signals to other contributors that the directory is not part of this repository:</p><ul><li><strong>git</strong> — <code>plugins/<name>.local</code> is covered by the root <code>.gitignore</code>'s generic <code>plugins/*/</code> catch-all and is never tracked or committed here.</li><li><strong>pnpm</strong> — it is a full workspace member, so <code>pnpm dev</code> resolves its dependencies and serves its routes live at the plugin's <code>routePrefix</code>, exactly like a first-party plugin. No symlinks, no separate install step.</li><li><strong>pre-commit hook</strong> — <code>scripts/validate-plugin-boundary.ts</code> runs on every commit and automatically removes <code>pnpm-lock.yaml</code> and <code>runtime/generated/registry.ts</code> from the staged set if they contain entries for an untracked plugin directory — your on-disk files are left untouched. You never need to clean up manually before committing unrelated platform work.</li></ul><p>The plugin's own source files stay in their own git history; only the platform's generated outputs drift locally, and the pre-commit hook handles those silently.</p><h4 id="choosing-a-database-mode" tabindex="-1">Choosing a database mode <a class="header-anchor" href="#choosing-a-database-mode" aria-label="Permalink to "Choosing a database mode""></a></h4><p>Set <code>"database"</code> in your manifest to a string value or the object form:</p><table tabindex="0"><thead><tr><th>Value</th><th>What it means</th></tr></thead><tbody><tr><td><code>"isolated"</code></td><td>Plugin owns a dedicated SQLite file / Postgres schema. No risk of table conflicts with the platform or other plugins. <strong>Recommended for all third-party plugins.</strong></td></tr><tr><td><code>"shared"</code></td><td>Plugin writes into the platform DB. Can join against platform tables (tenants, users, etc.). <strong>For trusted first-party plugins only</strong> — a schema conflict or buggy migration can corrupt platform data.</td></tr></tbody></table><p>Omitting <code>"database"</code> is equivalent to <code>"shared"</code>.</p><p>Object form is useful when an isolated plugin should use SQLite even on a Postgres platform:</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "database"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "isolation"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"isolated"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "dialect"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"sqlite"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Allowed combinations:</p><table tabindex="0"><thead><tr><th>Platform dialect</th><th>Manifest declaration</th><th>Resolved plugin dialect</th><th>Allowed</th></tr></thead><tbody><tr><td>SQLite</td><td>omitted</td><td>SQLite</td><td>yes</td></tr><tr><td>SQLite</td><td><code>"isolated"</code></td><td>SQLite</td><td>yes</td></tr><tr><td>SQLite</td><td><code>{ "isolation": "isolated" }</code></td><td>SQLite</td><td>yes</td></tr><tr><td>SQLite</td><td><code>{ "isolation": "isolated", "dialect": "sqlite" }</code></td><td>SQLite</td><td>yes</td></tr><tr><td>Postgres</td><td>omitted</td><td>Postgres</td><td>yes</td></tr><tr><td>Postgres</td><td><code>"isolated"</code></td><td>Postgres</td><td>yes</td></tr><tr><td>Postgres</td><td><code>{ "isolation": "isolated" }</code></td><td>Postgres</td><td>yes</td></tr><tr><td>Postgres</td><td><code>{ "isolation": "isolated", "dialect": "sqlite" }</code></td><td>SQLite</td><td>yes</td></tr></tbody></table><p>The platform dialect is a ceiling. SQLite is embedded in every deployment, so a Postgres platform can host a SQLite-backed isolated plugin. A SQLite platform has no Postgres server to lend to a plugin, so manifests cannot declare <code>{ "dialect": "postgres" }</code>; the schema rejects it at validation time.</p><h4 id="database-setup-for-local-plugins" tabindex="-1">Database setup for local plugins <a class="header-anchor" href="#database-setup-for-local-plugins" aria-label="Permalink to "Database setup for local plugins""></a></h4><p>If your plugin declares a database mode, add migration files before running <code>pnpm dev</code>. The platform applies pending migrations at server startup — but it will error on the first boot if the migrations folder is missing or malformed.</p><p><strong>Required layout</strong> (same for both modes):</p><div class="language- vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang"></span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span>plugins/your-plugin.local/</span></span>
<span class="line"><span> manifest.json</span></span>
<span class="line"><span> migrations/</span></span>
<span class="line"><span> sqlite/</span></span>
<span class="line"><span> 0000_initial_schema.sql</span></span>
<span class="line"><span> meta/</span></span>
<span class="line"><span> _journal.json ← Drizzle journal — every migration must be registered here</span></span></code></pre></div><p><strong><code>meta/_journal.json</code> format:</strong></p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "version"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"7"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "dialect"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"sqlite"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "entries"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "idx"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "version"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"6"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "when"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">1751270400000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "tag"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"0000_initial_schema"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "breakpoints"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">true</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Each SQL file gets one entry. <code>tag</code> is the filename without <code>.sql</code>. <code>when</code> is a Unix millisecond timestamp (any reasonable value; used for display only).</p><p>For <code>isolated</code> plugins the migration runs against the plugin's own DB file in <code>data/plugins/</code>. For <code>shared</code> plugins it runs against the platform DB (<code>data/sovereign.db</code>) — table-name prefixing (e.g. <code>tasks_</code>, <code>myapp_</code>) is mandatory to avoid conflicts.</p><h4 id="applying-migrations-without-restarting-the-server" tabindex="-1">Applying migrations without restarting the server <a class="header-anchor" href="#applying-migrations-without-restarting-the-server" aria-label="Permalink to "Applying migrations without restarting the server""></a></h4><p>When you add a new migration file, apply it immediately without restarting:</p><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># Apply pending migrations for a specific plugin (by manifest ID or dir name)</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">pnpm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sv</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugin</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> migrate</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> fs.sovereign.your-plugin</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># Apply pending migrations for all plugins in plugins/</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">pnpm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sv</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugin</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> migrate</span></span></code></pre></div><p>The command reads from your plugin's <code>migrations/sqlite/</code> folder and updates the DB (plugin file for <code>isolated</code>, platform DB for <code>shared</code>). The running dev server picks up the new schema on the next request — no restart needed.</p><p>See <a href="./plugin-database.html"><code>docs/plugin-database.md</code></a> for the full migration reference: SQL conventions, journal format, Postgres variant, lifecycle, and backup.</p><h2 id="accessibility" tabindex="-1">Accessibility <a class="header-anchor" href="#accessibility" aria-label="Permalink to "Accessibility""></a></h2><p>Sovereign targets <strong>WCAG 2.1 AA</strong> on all platform-owned UI, and plugin developers are expected to ship accessible plugins. The <code>eslint-plugin-jsx-a11y</code> recommended ruleset is enforced across the entire monorepo — <code>pnpm lint</code> will catch common violations at build time.</p><h3 id="semantic-html" tabindex="-1">Semantic HTML <a class="header-anchor" href="#semantic-html" aria-label="Permalink to "Semantic HTML""></a></h3><p>Use the correct native element for the job. Browsers provide free keyboard behaviour, role announcements, and focus management:</p><ul><li><code><button></code> for actions, <code><a></code> for navigation, <code><input></code> for form fields</li><li><code><nav></code>, <code><main></code>, <code><section></code>, <code><header></code>, <code><footer></code> for landmarks</li><li><code><ul></code> / <code><ol></code> + <code><li></code> for lists — never add <code>role="list"</code> (it is implicit on <code><ul></code>)</li><li><code><table></code> / <code><th></code> / <code><td></code> for tabular data — not CSS grids dressed as tables</li></ul><h3 id="form-labels" tabindex="-1">Form labels <a class="header-anchor" href="#form-labels" aria-label="Permalink to "Form labels""></a></h3><p>Every form control must have a visible, programmatically associated label. The <code>Input</code> component accepts <code>id</code> and spreads it to the underlying <code><input></code> — always pair it with <code>htmlFor</code>:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">label</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> htmlFor</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"plugin-title"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> Title</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Input</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> id</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"plugin-title"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> type</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"text"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> value</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{title} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">onChange</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">} /></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">label</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span></code></pre></div><p>Placeholder text is not a label — it disappears on input and is never announced as the field's accessible name.</p><h3 id="icon-accessibility" tabindex="-1">Icon accessibility <a class="header-anchor" href="#icon-accessibility" aria-label="Permalink to "Icon accessibility""></a></h3><p>The <code><Icon></code> component from <code>@sovereignfs/ui</code> enforces the correct pattern via prop types:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Decorative icon (next to visible text) — hide from screen readers</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Icon</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> name</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"trash-2"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> size</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"md"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> aria-hidden</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> /></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Meaningful icon (no adjacent text) — provide a label</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Icon</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> name</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"log-out"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> aria-label</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Sign out"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> /></span></span></code></pre></div><p>Never use emoji or Unicode symbols as icons in interactive UI — they have inconsistent screen-reader announcements.</p><h3 id="keyboard-operability" tabindex="-1">Keyboard operability <a class="header-anchor" href="#keyboard-operability" aria-label="Permalink to "Keyboard operability""></a></h3><p>Every feature a mouse user can reach must be reachable by keyboard alone:</p><ul><li>All interactive elements must be in the tab order (or reachable via a documented keyboard shortcut)</li><li>Custom widgets (menus, comboboxes, trees, carousels) must follow the <a href="https://www.w3.org/WAI/ARIA/apg/" target="_blank" rel="noreferrer">ARIA Authoring Practices Guide</a> keyboard conventions for their role</li><li>Never use <code>div</code> or <code>span</code> with <code>onClick</code> — use <code><button></code> or an element with an appropriate role, <code>tabIndex</code>, and keyboard handler</li></ul><h3 id="colour-independence" tabindex="-1">Colour independence <a class="header-anchor" href="#colour-independence" aria-label="Permalink to "Colour independence""></a></h3><p>Never convey meaning through colour alone. Pair colour with an icon, label, or pattern:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// ✗ colour-only: fails for colour-blind users</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">span</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> style</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{{ color: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">'red'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }}>Error</</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">span</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// ✓ colour + text/icon</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"><</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">span</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> className</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{styles.error}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">Icon</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> name</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"alert-circle"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> aria-hidden</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> /></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> Error: field is required</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"></</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">span</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span></code></pre></div><p>Use <code>--sv-color-error-*</code>, <code>--sv-color-warning-*</code>, and <code>--sv-color-success-*</code> tokens (not hardcoded hex) so the palette remains consistent and accessible in dark mode.</p><h3 id="live-regions" tabindex="-1">Live regions <a class="header-anchor" href="#live-regions" aria-label="Permalink to "Live regions""></a></h3><p>For status messages that appear without a page reload (async saves, error toasts, form validation), use <code>role="status"</code> (polite) or <code>role="alert"</code> (assertive) so screen readers announce the change:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> error </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">&&</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">p</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> role</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"alert"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> className</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{styles.error}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {error}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> </</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">p</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> );</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> saved </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">&&</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">p</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> role</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"status"</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> className</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{styles.notice}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> Saved.</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> </</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">p</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> );</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><h3 id="reduced-motion" tabindex="-1">Reduced motion <a class="header-anchor" href="#reduced-motion" aria-label="Permalink to "Reduced motion""></a></h3><p>Animate only when the user has not requested reduced motion:</p><div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">.mySlideIn</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> animation</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: slideIn </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">200</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">ms</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> ease-out</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">@media</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (prefers-reduced-motion: reduce) {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> .mySlideIn</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> animation</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">none</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><h3 id="custom-aria-patterns" tabindex="-1">Custom ARIA patterns <a class="header-anchor" href="#custom-aria-patterns" aria-label="Permalink to "Custom ARIA patterns""></a></h3><p>If you must build a custom interactive widget (tabs, accordion, carousel), follow the <a href="https://www.w3.org/WAI/ARIA/apg/" target="_blank" rel="noreferrer">WAI-ARIA Authoring Practices</a> for the corresponding pattern. Key points:</p><ul><li>Assign the correct <code>role</code> to the container and children</li><li>Manage <code>aria-selected</code>, <code>aria-expanded</code>, <code>aria-controls</code>, <code>aria-labelledby</code> as the pattern requires</li><li>Implement the full keyboard model (arrow keys, Home/End, Enter/Space) expected for that role</li><li>Ensure focus is moved programmatically when content changes visibility</li></ul><h2 id="building-for-mobile" tabindex="-1">Building for mobile <a class="header-anchor" href="#building-for-mobile" aria-label="Permalink to "Building for mobile""></a></h2><p>Sovereign runs as an installable PWA as well as a browser tab, and the platform shell already carries the baseline that makes that feel native rather than "a website on a phone" — global touch hygiene, safe-area insets, the mobile breakpoint, and gesture primitives all live in <code>@sovereignfs/ui</code> and <code>runtime/app/globals.css</code>. This section is the practical, plugin-author-facing version of that; the full component-level internals (why each rule exists, exact token values, CSS specifics) live in <a href="./design-system.html#responsive--mobile"><code>docs/design-system.md</code></a> — this section links out to it rather than duplicating it.</p><h3 id="breakpoint" tabindex="-1">Breakpoint <a class="header-anchor" href="#breakpoint" aria-label="Permalink to "Breakpoint""></a></h3><p>Fork behaviour by viewport with <code>useIsMobile()</code>, not a hand-rolled <code>matchMedia</code> call — it is SSR-safe (defaults to <code>false</code> until the client mounts, avoiding a hydration mismatch) and defaults to the platform's single canonical breakpoint:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { useIsMobile } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Toolbar</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> isMobile</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> useIsMobile</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(); </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// true at ≤768px</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> isMobile </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">?</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">MobileToolbar</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> /> </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">DesktopToolbar</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> />;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>768px is the platform default (matches the shell chrome and <code>Dialog</code>'s own mobile switch) — reach for it first. A layout with a genuinely different fork point (e.g. a three-column layout that needs to collapse earlier) may pass its own <code>breakpointPx</code>, but document why inline; an undocumented custom threshold is how breakpoints silently drift across a codebase. See <a href="./design-system.html#breakpoint-convention">design-system.md's breakpoint convention</a> for the full rationale.</p><h3 id="touch-targets" tabindex="-1">Touch targets <a class="header-anchor" href="#touch-targets" aria-label="Permalink to "Touch targets""></a></h3><p>Every tappable control needs a <strong>44px</strong> minimum hit area (Apple HIG / Material Design / WCAG 2.5.5) — below that, taps misfire and read as broken UI, not just "small." <code>@sovereignfs/ui</code> components already handle this themselves (<code>Button</code> grows to 44px under <code>@media (pointer: coarse)</code>; <code>Checkbox</code>'s hit area expands past its visible 18px box the same way); if you build your own icon-only control, size it against <code>--sv-touch-target-min</code>:</p><div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">.myIconButton</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> min-width</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">var</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">--sv-touch-target-min</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">44</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">px</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> min-height</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">var</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">--sv-touch-target-min</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">44</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">px</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Gate any coarse-pointer-only sizing behind <code>@media (pointer: coarse)</code>, not a viewport-width media query — a touchscreen laptop with a mouse/trackpad as its primary pointer should keep desktop density, and <code>pointer: coarse</code> (the <em>primary</em> pointer) is what distinguishes that from an actual touch device. See <a href="./design-system.html#touch-targets-—-sv-touch-target-min">design-system.md's touch-target and Button/Checkbox sections</a> for the full pattern.</p><h3 id="hover-guards" tabindex="-1">Hover guards <a class="header-anchor" href="#hover-guards" aria-label="Permalink to "Hover guards""></a></h3><p>Every <code>:hover</code> rule needs <code>@media (hover: hover)</code>, or a tap generates a synthetic hover state that <strong>sticks</strong> until the next tap elsewhere — a button reads as stuck mid-transition to its hover color after the tap already completed its action:</p><div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">@media</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">hover</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">hover</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> .myControl:hover</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> background-color</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">var</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">--sv-color-surface-sunken</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p><code>:focus-visible</code> and <code>:active</code> are never guarded — both are wanted on every input type. <code>@sovereignfs/ui</code> follows this convention throughout; apply the same guard in your own plugin CSS. Full writeup, including the hover-<em>reveal</em> case (<code>:not(:hover)</code> is unconditionally true with no hover capability at all): <a href="./design-system.html#hover-guard-convention-—-media-hover-hover">design-system.md's hover guard convention</a>.</p><h3 id="the-long-press-recipe" tabindex="-1">The long-press recipe <a class="header-anchor" href="#the-long-press-recipe" aria-label="Permalink to "The long-press recipe""></a></h3><p>A bare <code>setTimeout</code> on <code>pointerdown</code> is not a long-press gesture — it misfires on finger jitter, survives a <code>pointercancel</code> (the browser converting the touch into a scroll) and fires mid-scroll, and does nothing to suppress the OS's own reaction to a long hold (iOS's link-preview callout, Android's context menu). <code>useLongPress</code> carries the full fix:</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { useLongPress } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> TaskRow</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">task</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">onSelect</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">task</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> Task</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">onSelect</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> () </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> longPress</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> useLongPress</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ onLongPress: onSelect });</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">div</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">longPress} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">className</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{styles.row}></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {task.title}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> </</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">div</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> );</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>The returned props (<code>onPointerDown</code>/<code>onPointerMove</code>/<code>onPointerUp</code>/<code>onPointerCancel</code>/<code>onPointerLeave</code>/<code>onContextMenu</code>/<code>onClick</code>/<code>style</code>) spread directly onto the target element — nothing else to wire up. It only ever fires for genuine touch input (<code>pointerType === 'touch'</code>); a mouse holding the same element for 500ms never triggers it, so desktop interactions (e.g. ctrl/cmd-click for bulk select) are unaffected. Full mechanism: <a href="./design-system.html#interaction-hooks">design-system.md's interaction hooks section</a>.</p><h3 id="double-tap-—-use-sparingly-and-prefer-an-explicit-affordance" tabindex="-1">Double-tap — use sparingly, and prefer an explicit affordance <a class="header-anchor" href="#double-tap-—-use-sparingly-and-prefer-an-explicit-affordance" aria-label="Permalink to "Double-tap — use sparingly, and prefer an explicit affordance""></a></h3><p><code>useDoubleTapHandler</code> and <code>useSingleOrDoubleTap</code> exist for double-tap gestures, but <strong><code>useSingleOrDoubleTap</code> defers every single tap by the double-tap detection window (350ms)</strong> — touch has no equivalent to a mouse's native <code>e.detail === 2</code>, so the only way to know a second tap isn't coming is to wait out the window before committing to the single action. That latency is paid on <strong>every</strong> tap through it, not just the double, which reads as sluggish on a primary navigation gesture.</p><p>Before reaching for double-tap on mobile, consider whether the action it guards (rename, secondary options) is better served by an explicit affordance instead — a visible "⋯" button, or <code>useLongPress</code> — so a single tap can navigate immediately with no latency tax. This is a real lesson from the reference implementation: <code>sovereign-tasks</code> originally double-tap-to-renamed a list row on mobile, paying the 350ms tax on every navigating tap; it was replaced with immediate single-tap navigation plus an explicit "⋯" menu entry for rename.</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { useDoubleTapHandler } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// Safe here: the single tap on a colour swatch has no default action to preempt.</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> ColorSwatch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">color</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">onPick</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">color</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> string</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">onPick</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> () </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> handleDoubleClick</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> useDoubleTapHandler</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(onPick);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">button</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> onClick</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{handleDoubleClick} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">style</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{{ background: color }} />;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><h3 id="committing-quick-entry-input-—-enter-vs-ios-s-keyboard-toolbar" tabindex="-1">Committing quick-entry input — Enter vs. iOS's keyboard toolbar <a class="header-anchor" href="#committing-quick-entry-input-—-enter-vs-ios-s-keyboard-toolbar" aria-label="Permalink to "Committing quick-entry input — Enter vs. iOS's keyboard toolbar""></a></h3><p>iOS Safari adds its own "Previous / Next / Done" toolbar above the software keyboard whenever a Sheet or form has more than one focusable field nearby — there is no supported way to suppress it (it's WebKit's own field-detection heuristic, not something the page controls). Tapping that toolbar's Done/checkmark only ever fires a native <code>blur</code>; it is <strong>not</strong> a form submit and dispatches no keydown. A quick-entry input that commits only on Enter (<code>onKeyDown</code> checking <code>e.key === 'Enter'</code>) silently discards whatever was typed the moment a user dismisses the keyboard that way instead of pressing the on-screen Return key — the two dismissal paths look identical to the user but produce different outcomes. This was a real bug across <code>sovereign-tasks</code> and <code>sovereign-shopper</code>'s add-list/add-task/add-subtask rows before <code>useCommitOnEnterOrBlur</code> existed.</p><div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { useCommitOnEnterOrBlur } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> '@sovereignfs/ui'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> AddTaskRow</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">({ </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">onAdd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">onAdd</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">title</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> string</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> [</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">title</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">setTitle</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> useState</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> commit</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> () </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> trimmed</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> title.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">trim</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">trimmed) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">;</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> onAdd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(trimmed);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> setTitle</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> };</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> commitHandlers</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> =</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> useCommitOnEnterOrBlur</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(commit);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;"> return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> <</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">input</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> value</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{title} </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">onChange</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70;">e</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">=></span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;"> setTitle</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">(e.target.value)} {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583;">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">commitHandlers} />;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Reach for this whenever Enter is the <em>sole</em> way to commit a field (a quick-add row, an inline rename with no persistent Save button). It is the wrong tool for a field inside a form with its own always-visible submit button (login, a dialog's "Save", payment details) — there, silently submitting on blur would be surprising and possibly unsafe; leave Enter as a convenience shortcut and let the visible button be the real commit action. Full mechanism: <a href="./design-system.html#interaction-hooks">design-system.md's interaction hooks section</a>.</p><h3 id="pwa-feel-checklist" tabindex="-1">PWA-feel checklist <a class="header-anchor" href="#pwa-feel-checklist" aria-label="Permalink to "PWA-feel checklist""></a></h3><p>The shell already handles the following globally — nothing to add per plugin:</p><ul><li>[x] No translucent tap-highlight flash (<code>-webkit-tap-highlight-color: transparent</code>)</li><li>[x] No text auto-inflation on orientation change (<code>text-size-adjust: 100%</code>)</li><li>[x] No ~300ms tap delay / double-tap-to-zoom on interactive elements (<code>touch-action: manipulation</code> on links, buttons, inputs, <code>[role="button"]</code>)</li><li>[x] No iOS Safari rubber-band bounce at the document level (<code>overscroll-behavior: none</code>)</li><li>[x] No iOS Safari viewport zoom on focusing a small-font input (native inputs clamped to ≥16px in standalone/PWA mode)</li><li>[x] Safe-area insets (<code>env(safe-area-inset-*)</code>) already factored into the shell header/footer and <code>Dialog</code>'s mobile inset</li></ul><p>Still your responsibility per component:</p><ul><li>[ ] A custom drag handle (dnd-kit or hand-rolled) sets its own <code>touch-action: none</code> — the global <code>manipulation</code> default lets the browser's own scroll/zoom gestures compete with a drag unless overridden</li><li>[ ] Any element you position <code>fixed</code> or <code>sticky</code> yourself accounts for <code>env(safe-area-inset-bottom)</code> if it can sit near the device's home-indicator area — <code>@sovereignfs/ui</code>'s own overlays (<code>Drawer</code>, <code>Sheet</code>, <code>Dialog</code>) already do this; a plugin-local fixed element does not get it for free</li><li>[ ] A quick-entry input that only commits on Enter also commits on blur via <code>useCommitOnEnterOrBlur</code> — see "Committing quick-entry input" above; otherwise iOS's native keyboard toolbar silently discards typed input</li><li>[ ] Test in <strong>both</strong> Safari-the-browser-tab and the installed <strong>PWA standalone</strong> mode on iOS — behaviour genuinely differs (the standalone-mode zoom-persists-after-blur case above is one example) and testing only the browser tab misses it</li><li>[ ] Verify on Android Chrome too — long-press, swipe, and scroll-vs-gesture arbitration have platform-specific quirks that don't always match iOS</li></ul><h2 id="publishing-the-registry" tabindex="-1">Publishing & the registry <a class="header-anchor" href="#publishing-the-registry" aria-label="Permalink to "Publishing & the registry""></a></h2><p>To distribute a plugin, set <code>type</code> to <code>sovereign</code> or <code>community</code> and point <code>repository</code> at its public git URL. Today, instances install declared plugins from <code>sovereign.plugins.json</code> — a local, gitignored file each operator maintains (see <a href="./self-hosting.html#bundled-default-plugins">self-hosting.md</a>); it's not part of the committed repository:</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "plugins"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"io.example.tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"repository"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"https://github.com/you/sovereign-plugin-tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><h3 id="private-repositories" tabindex="-1">Private repositories <a class="header-anchor" href="#private-repositories" aria-label="Permalink to "Private repositories""></a></h3><p>A plugin's repository doesn't have to be public. Add an optional <code>tokenEnv</code> field naming an environment variable that holds a personal access token — the <strong>variable name</strong> is what you write, never the token itself, so it's safe even though <code>sovereign.plugins.json</code> is a local, gitignored file (epic task 3.31) rather than something meant to be shared or committed:</p><div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "plugins"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"com.acme.crm"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "repository"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"https://github.com/acme/sovereign-crm"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "tokenEnv"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"ACME_CRM_PLUGIN_TOKEN"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Set <code>ACME_CRM_PLUGIN_TOKEN</code> in the environment before running <code>pnpm install:plugins</code>. <code>tokenEnv</code> requires an <code>https://</code> <code>repository</code> URL (an SSH URL authenticates via your shell's own SSH key/agent instead, with no field needed). The one-off equivalent is <code>sv plugin add</code>'s <code>--token-env</code> flag:</p><div class="language-sh vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">sh</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">pnpm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> sv</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> plugin</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> add</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> https://github.com/acme/sovereign-crm</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> --token-env</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> ACME_CRM_PLUGIN_TOKEN</span></span></code></pre></div><p>The token is only ever read at clone time — written to a short-lived, mode-<code>0600</code> git credential file for the duration of the <code>git clone</code>/<code>fetch</code> call, then deleted. It is never embedded in a logged URL or passed as a process argument. A subsequent <code>pnpm install:plugins</code> run skips a plugin already present under <code>plugins/<id>/</code>, so the token is only needed again if that directory is deleted and re-cloned — not on every rebuild. See "Maintaining a fork" and "Private plugins on a hosted instance" in <a href="./self-hosting.html"><code>docs/self-hosting.md</code></a> for the full operator workflow, including what's required for a private plugin to survive version upgrades.</p><h3 id="submitting-to-the-registry" tabindex="-1">Submitting to the registry <a class="header-anchor" href="#submitting-to-the-registry" aria-label="Permalink to "Submitting to the registry""></a></h3><p>The <a href="../registry/plugins.json"><code>registry/plugins.json</code></a> file is the public index of installable plugins — listing there makes a plugin discoverable. The registry stores a <strong>thin record</strong> per plugin — a pointer to your source plus display metadata — <strong>not</strong> a copy of your manifest. The manifest stays in your repository and is fetched from there at install time, so it never drifts out of sync with the registry. An entry is:</p><div class="language-jsonc vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsonc</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "id"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"io.example.tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "repository"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "type"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"git"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "url"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"https://github.com/you/sovereign-plugin-tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "ref"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"v1.0.0"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Tasks"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "description"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"A simple task manager."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "author"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"Ada Lovelace"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">"email"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"ada@example.com"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "license"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"MIT"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// SPDX identifier</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> "keywords"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">"productivity"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">], </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;">// optional</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> // "provenance" is added by `pnpm registry:validate` — do not hand-write it.</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">}</span></span></code></pre></div><p>Operational fields (<code>version</code>, <code>permissions</code>, <code>compatibility</code>, …) are <strong>not</strong> duplicated in the registry; they come from the fetched manifest. A submission must:</p><ul><li>be a <strong>valid registry entry</strong> (validated by the <code>registry/__tests__</code> suite via <code>validateRegistryEntry</code>, which fails CI on an invalid entry);</li><li>point <code>repository</code> at a <strong>public/accessible</strong> source (<code>{ type: "git", url, ref? }</code>, or <code>{ type: "path", url }</code> for a first-party/local source);</li><li>have a <strong>valid manifest</strong> at that source (<code>type: "sovereign"</code>/<code>"community"</code>, <code>id</code> matching the entry);</li><li>include a <strong><code>LICENSE</code></strong> file in that source, an SPDX <strong><code>license</code></strong>, an <strong><code>author</code></strong>, a <strong>compatible</strong> <code>compatibility.minPlatformVersion</code>, and a <strong>globally-unique</strong> <code>id</code>.</li></ul><p>Before opening the PR, run <strong><code>pnpm registry:validate</code></strong>: it clones your source, checks the manifest and LICENSE, hashes the source tree, and writes a <code>provenance</code> block (resolved commit + content hash) into your entry. The <strong>Registry validate</strong> CI job re-runs <code>pnpm registry:check</code> to confirm the hash is fresh. The full process, requirements, and PR template are in <a href="https://github.com/sovereignfs/sovereign/blob/main/registry/CONTRIBUTING.md" target="_blank" rel="noreferrer"><code>registry/CONTRIBUTING.md</code></a>. Until your plugin is listed, you can still share your repository URL and instances add it to <code>sovereign.plugins.json</code> as above.</p></div></div></main><footer class="VPDocFooter" data-v-d9bf7dc2 data-v-83d8eeda><!--[--><!--]--><!----><nav class="prev-next" aria-labelledby="doc-footer-aria-label" data-v-83d8eeda><span class="visually-hidden" id="doc-footer-aria-label" data-v-83d8eeda>Pager</span><div class="pager" data-v-83d8eeda><a class="VPLink link pager-link prev" href="/troubleshooting.html" data-v-83d8eeda><!--[--><span class="desc" data-v-83d8eeda>Previous page</span><span class="title" data-v-83d8eeda>Troubleshooting</span><!--]--></a></div><div class="pager" data-v-83d8eeda><a class="VPLink link pager-link next" href="/sdk-stability.html" data-v-83d8eeda><!--[--><span class="desc" data-v-83d8eeda>Next page</span><span class="title" data-v-83d8eeda>SDK Stability</span><!--]--></a></div></nav></footer><!--[--><!--]--></div></div></div><!--[--><!--]--></div></div><footer class="VPFooter has-sidebar" data-v-715c0237 data-v-0094cebd><div class="container" data-v-0094cebd><p class="message" data-v-0094cebd>Open source under AGPL-3.0. Each Sovereign instance is independently operated.</p><p class="copyright" data-v-0094cebd>Sovereign</p></div></footer><!--[--><!--]--></div></div>
<script>window.__VP_HASH_MAP__=JSON.parse("{\"agent-first-documentation.md\":\"CmQ9uZ2U\",\"architecture-rules.md\":\"Cx4aTRzE\",\"architecture.md\":\"pHZYMije\",\"design-system.md\":\"BCuXfBph\",\"development-workflow.md\":\"DSt8dR4f\",\"docs_architecture.md\":\"CTvtqEJD\",\"docs_contributing.md\":\"B42xq8z5\",\"docs_developers.md\":\"u0c0kF0l\",\"docs_index.md\":\"C1416hhU\",\"docs_operators.md\":\"BzmbkU9Y\",\"docs_pwa.md\":\"C5wA0Xg-\",\"docs_users.md\":\"CkufVO3w\",\"documentation-structure.md\":\"C8TjlHhZ\",\"get-started_developers.md\":\"Cd21BajS\",\"get-started_index.md\":\"wEQNAJKH\",\"get-started_operators.md\":\"DfsGlfLb\",\"get-started_users.md\":\"DvHHggUB\",\"index.md\":\"C7tElUdp\",\"instances.md\":\"BF3w1-Ow\",\"plugin-database.md\":\"B0HKu7yT\",\"plugin-development.md\":\"CwXd-zw0\",\"plugins_account.md\":\"BTJtLwJt\",\"plugins_console.md\":\"C29qkKyl\",\"plugins_launcher.md\":\"BX-BziDV\",\"product-roadmap.md\":\"-EY1dEXj\",\"product_apps.md\":\"Dif68fZ2\",\"product_features.md\":\"BVncgaJX\",\"product_how-it-works.md\":\"Bu8Ef18G\",\"product_index.md\":\"D0cgdprY\",\"product_why-sovereign.md\":\"DEHJ0FlN\",\"pwa-real-device-testing.md\":\"CItwnEQF\",\"repositories.md\":\"CljuMT6q\",\"rfcs_0001-overlay-shell-variant.md\":\"vvJidCFH\",\"rfcs_0002-cross-plugin-data-sharing.md\":\"DM5YxVRN\",\"rfcs_0003-plugin-monetization.md\":\"cq6Tk2-Y\",\"rfcs_0004-per-plugin-database.md\":\"CwnzpSci\",\"rfcs_0005-activity-log.md\":\"DYzfMNp9\",\"rfcs_0006-deployment-upgrade-strategy.md\":\"CM6iGaWr\",\"rfcs_0007-user-data-portability.md\":\"sXYh6TM7\",\"rfcs_0008-security-encryption-architecture.md\":\"DcoOkSLt\",\"rfcs_0009-internal-package-codenames.md\":\"BWHdFpI2\",\"rfcs_0010-test-organization.md\":\"Bqc9joPJ\",\"rfcs_0011-icon-system.md\":\"5Sv8QWaY\",\"rfcs_0012-passkeys-and-mfa.md\":\"DjI-taH4\",\"rfcs_0013-mobile-responsiveness-pwa.md\":\"DwZoKtJb\",\"rfcs_0014-minimal-shell-mode.md\":\"D7ZykUa0\",\"rfcs_0015-notification-center.md\":\"QNs0dD6A\",\"rfcs_0016-web-push.md\":\"CGSa5XX2\",\"rfcs_0017-plugin-starter-and-examples.md\":\"BAri0NZb\",\"rfcs_0018-plugin-scoped-env.md\":\"CZwRAMom\",\"rfcs_0019-test-setup-and-seeding.md\":\"DpwFekRi\",\"rfcs_0020-production-dev-mode.md\":\"Di6ptx6Y\",\"rfcs_0021-platform-roles-and-capabilities.md\":\"8HUNQr4u\",\"rfcs_0022-plugin-capabilities.md\":\"Dob9F4yZ\",\"rfcs_0023-sdk-distribution.md\":\"Bj-eYkYo\",\"rfcs_0024-plugin-compatibility.md\":\"CshTbJfZ\",\"rfcs_0025-accessibility.md\":\"jCEAUNa-\",\"rfcs_0026-non-docker-deployment.md\":\"CRQvrbmp\",\"rfcs_0027-white-labeling.md\":\"B7WSxyFt\",\"rfcs_0028-operator-fork-model.md\":\"D4VfDNFP\",\"rfcs_0029-internationalization.md\":\"BzpEhgCU\",\"rfcs_0030-privacy-first-analytics.md\":\"X7QiqoZ5\",\"rfcs_0031-email-templates.md\":\"Dn1TDcGo\",\"rfcs_0032-instance-identity-rename.md\":\"Cgcv5Ppw\",\"rfcs_0033-user-data-deletion.md\":\"TRHdaPr0\",\"rfcs_0034-notification-transport.md\":\"DRD3C2o2\",\"rfcs_0035-progressive-user-verification.md\":\"BNvXOJIB\",\"rfcs_0036-per-plugin-dialect.md\":\"BigHhmEf\",\"rfcs_0037-vitepress-docs-site.md\":\"BEx8SX-o\",\"rfcs_0038-desktop-app-shell.md\":\"Dt0samoy\",\"rfcs_0039-instance-id-and-terminology.md\":\"DPzX0tQF\",\"rfcs_0040-sovereign-harness.md\":\"CMAO2wm4\",\"rfcs_0041-user-directory.md\":\"DE8UEQ26\",\"rfcs_0042-public-plugin-routes.md\":\"CuN3jeiW\",\"rfcs_0043-plugin-secret-vault.md\":\"CYVwkrzl\",\"rfcs_0044-plugin-storage.md\":\"f2OlNBqK\",\"rfcs_0045-plugin-events.md\":\"1eXtUWDm\",\"rfcs_0046-plugin-jobs.md\":\"C7ZB6kYP\",\"rfcs_0047-plugin-tools.md\":\"9VhGqGjO\",\"rfcs_0048-messages-and-notification-detail.md\":\"BxempQdG\",\"rfcs_0049-plugin-external-connections.md\":\"DDs1tJNq\",\"rfcs_0050-public-plugin-webhooks.md\":\"CZqrqXr1\",\"rfcs_0051-cross-plugin-references.md\":\"CxcbuQRM\",\"rfcs_0052-plugin-portability-hooks.md\":\"CG3w5i84\",\"rfcs_0053-plugin-flow-handoffs.md\":\"p9jyXoOp\",\"rfcs_0054-plugin-scoped-roles-and-grants.md\":\"6FQAVqyQ\",\"rfcs_0055-sovereign-council.md\":\"C24pgP24\",\"rfcs_0056-sovereign-guide.md\":\"MYmlqvzs\",\"rfcs_0057-plugin-dep-hoisting.md\":\"DT5r1KJO\",\"rfcs_0058-native-mobile-app-shell.md\":\"BbQWeRx4\",\"rfcs_0059-local-visual-regression-testing.md\":\"BoVadqeA\",\"rfcs_0060-client-side-encryption-core.md\":\"CYSfngI_\",\"rfcs_0061-sovereign-wallet.md\":\"3-YMVh3T\",\"rfcs_0062-email-delivery-coverage.md\":\"BvhhuQrj\",\"rfcs_0063-core-assistant-jarvis.md\":\"CtWnYbDP\",\"rfcs_0064-git-backed-operator-backups.md\":\"CPFMz5c2\",\"rfcs_0065-user-groups-plugin-access.md\":\"BI6AH5kN\",\"rfcs_0066-sovereign-chat-p2p-identity.md\":\"Bnqamz2T\",\"rfcs_0067-product-led-docs-site.md\":\"C90FKbj5\",\"rfcs_0068-export-completeness-hardening.md\":\"P1Kcc-5s\",\"rfcs_0069-bring-your-own-database.md\":\"BCtSkrVT\",\"rfcs_0070-per-user-capability-grants.md\":\"Cr5mZ3uV\",\"rfcs_0071-sqlite-at-rest-encryption.md\":\"D30oSdgW\",\"rfcs_0072-external-oauth-provider.md\":\"CPD3TrpW\",\"rfcs_0073-standalone-ui-package.md\":\"EMqC_8lx\",\"rfcs_0074-offline-capable-plugins.md\":\"BF_6VrJ7\",\"rfcs_0075-mobile-chrome-toggle.md\":\"CvSkwFS_\",\"rfcs_0076-ds-sizing-alignment-and-new-primitives.md\":\"GNuVs8zd\",\"rfcs_0077-instance-radius-control.md\":\"B7BB5aCj\",\"rfcs_0078-offline-plugin-writes.md\":\"rKKRaOKS\",\"rfcs_0079-mobile-pwa-layout-overlay-gesture-consistency.md\":\"DmpNSvEA\",\"rfcs_0080-plugin-surface-model.md\":\"BDPyngLw\",\"rfcs_0081-per-plugin-installable-pwa.md\":\"DJMWhDxn\",\"rfcs_0082-focused-plugin-app-shell.md\":\"D4_iPPcb\",\"rfcs_0083-device-bridge-capability-contract.md\":\"DQaUiEy_\",\"rfcs_readme.md\":\"MMMnqIGc\",\"sdk-stability.md\":\"AzSRLjD0\",\"security.md\":\"C_hafUnD\",\"self-hosting.md\":\"PAzKm1dp\",\"sovereign-edge_concept.md\":\"DtQrQOij\",\"sovereign-edge_development-workflow.md\":\"CKICNWtv\",\"sovereign-edge_epics_connector-framework.md\":\"aGMb644A\",\"sovereign-edge_epics_connector-store-sdk.md\":\"DeeWv2oF\",\"sovereign-edge_epics_core-inference-chat.md\":\"Bx4iN8-l\",\"sovereign-edge_epics_design-system.md\":\"T1iUS4ot\",\"sovereign-edge_epics_desktop-app.md\":\"CeXa6frv\",\"sovereign-edge_epics_infrastructure.md\":\"DBGFmEHg\",\"sovereign-edge_epics_mobile-app-shell.md\":\"DUyJewX1\",\"sovereign-edge_epics_monetization.md\":\"C5vBmuod\",\"sovereign-edge_epics_readme.md\":\"CJKDjtBD\",\"sovereign-edge_epics_search-connector.md\":\"kO5AXuQr\",\"sovereign-edge_epics_sovereign-tasks-connector.md\":\"IZLBbOxo\",\"sovereign-edge_index.md\":\"CVLqJ6G1\",\"sovereign-edge_research_0001-concept-and-connector-architecture.md\":\"CBLKABPV\",\"sovereign-edge_research_0002-react-native-framework-choice.md\":\"BdzEE9tA\",\"sovereign-edge_research_0003-model-verification-hashing.md\":\"4pbrthF-\",\"sovereign-edge_research_readme.md\":\"B1_HyndM\",\"sovereign-edge_roadmap.md\":\"CpWcYP2A\",\"sovereign-os_adrs_0001-phase-01-appliance-architecture.md\":\"D46xajjU\",\"sovereign-os_adrs_0002-install-images-and-update-artifacts.md\":\"C3L2S8mQ\",\"sovereign-os_adrs_0003-preview-bootstrap-access.md\":\"DIKCmAT8\",\"sovereign-os_adrs_0004-provider-neutral-assistant-and-web-search.md\":\"BLaVS-YP\",\"sovereign-os_adrs_0005-sovereign-console-and-health-boundary.md\":\"C2niKl8U\",\"sovereign-os_adrs_0006-production-signing-key-custody.md\":\"CWVIjHYQ\",\"sovereign-os_adrs_0007-console-authentication.md\":\"fPbnw3nN\",\"sovereign-os_adrs_0008-console-privileged-action-invocation.md\":\"DqdDv2uh\",\"sovereign-os_adrs_0009-console-triggered-install.md\":\"BjgqurX0\",\"sovereign-os_adrs_readme.md\":\"BLFKdZXp\",\"sovereign-os_concept.md\":\"BhHrt-cM\",\"sovereign-os_index.md\":\"533qWSwP\",\"sovereign-os_product_core-use-cases.md\":\"CRe30f0V\",\"sovereign-os_product_preview-scope.md\":\"JgpI2elD\",\"sovereign-os_product_target-user.md\":\"D_eNIS0o\",\"sovereign-os_product_terminology.md\":\"D0XrNM2B\",\"sovereign-os_rfcs_0010-raspberry-pi-image-deployment.md\":\"ZsxEbkml\",\"sovereign-os_rfcs_0014-appliance-update-system.md\":\"CvAyD5DN\",\"sovereign-os_rfcs_0015-update-discovery.md\":\"DL-9_SMg\",\"sovereign-os_rfcs_readme.md\":\"Cuxysy6k\",\"sovereign-os_roadmap.md\":\"dqbKdRM7\",\"testing-e2e.md\":\"Dc-biHyn\",\"troubleshooting.md\":\"BxCe9Han\",\"upgrade.md\":\"DZuTHIBf\"}");window.__VP_SITE_DATA__=JSON.parse("{\"lang\":\"en-US\",\"dir\":\"ltr\",\"title\":\"Sovereign\",\"description\":\"Sovereign is an open-source workspace runtime for hosting private, multi-user apps on infrastructure you control.\",\"base\":\"/\",\"head\":[],\"router\":{\"prefetchLinks\":true},\"appearance\":true,\"themeConfig\":{\"nav\":[{\"text\":\"Product\",\"items\":[{\"text\":\"Sovereign\",\"link\":\"/\"},{\"text\":\"Sovereign OS\",\"link\":\"/sovereign-os/\"},{\"text\":\"Sovereign Edge\",\"link\":\"/sovereign-edge/\"}]},{\"text\":\"Instances\",\"link\":\"/instances\"},{\"text\":\"Docs\",\"items\":[{\"text\":\"Get Started\",\"link\":\"/get-started/\"},{\"text\":\"Full Documentation\",\"link\":\"/docs/\"}]},{\"text\":\"Roadmap\",\"link\":\"/product-roadmap\"},{\"text\":\"GitHub\",\"link\":\"https://github.com/sovereignfs/sovereign\"}],\"sidebar\":{\"/product/\":[{\"text\":\"Product\",\"items\":[{\"text\":\"What is Sovereign?\",\"link\":\"/product/\"},{\"text\":\"Why Sovereign?\",\"link\":\"/product/why-sovereign\"},{\"text\":\"How It Works\",\"link\":\"/product/how-it-works\"},{\"text\":\"Features\",\"link\":\"/product/features\"},{\"text\":\"Apps\",\"link\":\"/product/apps\"}]}],\"/get-started/\":[{\"text\":\"Get Started\",\"items\":[{\"text\":\"Choose a Path\",\"link\":\"/get-started/\"},{\"text\":\"Use Sovereign\",\"link\":\"/get-started/users\"},{\"text\":\"Host Sovereign\",\"link\":\"/get-started/operators\"},{\"text\":\"Build an App\",\"link\":\"/get-started/developers\"}]}],\"/docs/\":[{\"text\":\"Documentation\",\"items\":[{\"text\":\"Documentation Home\",\"link\":\"/docs/\"},{\"text\":\"Use Sovereign\",\"link\":\"/docs/users\"},{\"text\":\"Install as an App\",\"link\":\"/docs/pwa\"},{\"text\":\"Operate Sovereign\",\"link\":\"/docs/operators\"},{\"text\":\"Build Apps\",\"link\":\"/docs/developers\"},{\"text\":\"Architecture & Security\",\"link\":\"/docs/architecture\"},{\"text\":\"Contribute\",\"link\":\"/docs/contributing\"}]}],\"/rfcs/\":[{\"text\":\"RFCs\",\"items\":[{\"text\":\"RFC Index\",\"link\":\"/rfcs/README\"},{\"text\":\"RFC 0001 — Overlay shell variant\",\"link\":\"/rfcs/0001-overlay-shell-variant\"},{\"text\":\"RFC 0002 — Cross-plugin data sharing\",\"link\":\"/rfcs/0002-cross-plugin-data-sharing\"},{\"text\":\"RFC 0003 — Plugin monetization\",\"link\":\"/rfcs/0003-plugin-monetization\"},{\"text\":\"RFC 0004 — Per-plugin database\",\"link\":\"/rfcs/0004-per-plugin-database\"},{\"text\":\"RFC 0005 — Activity log\",\"link\":\"/rfcs/0005-activity-log\"},{\"text\":\"RFC 0006 — Deployment & upgrade strategy\",\"link\":\"/rfcs/0006-deployment-upgrade-strategy\"},{\"text\":\"RFC 0007 — User data export & portability\",\"link\":\"/rfcs/0007-user-data-portability\"},{\"text\":\"RFC 0008 — Security & encryption architecture\",\"link\":\"/rfcs/0008-security-encryption-architecture\"},{\"text\":\"RFC 0009 — Internal package codenames\",\"link\":\"/rfcs/0009-internal-package-codenames\"},{\"text\":\"RFC 0010 — Test file organization\",\"link\":\"/rfcs/0010-test-organization\"},{\"text\":\"RFC 0011 — Icon system (Lucide)\",\"link\":\"/rfcs/0011-icon-system\"},{\"text\":\"RFC 0012 — Passkeys & TOTP multi-factor auth\",\"link\":\"/rfcs/0012-passkeys-and-mfa\"},{\"text\":\"RFC 0013 — Mobile responsiveness & PWA hardening\",\"link\":\"/rfcs/0013-mobile-responsiveness-pwa\"},{\"text\":\"RFC 0014 — Minimal shell mode\",\"link\":\"/rfcs/0014-minimal-shell-mode\"},{\"text\":\"RFC 0015 — Notification Center\",\"link\":\"/rfcs/0015-notification-center\"},{\"text\":\"RFC 0016 — Web Push notifications\",\"link\":\"/rfcs/0016-web-push\"},{\"text\":\"RFC 0017 — Plugin starter template & example plugins\",\"link\":\"/rfcs/0017-plugin-starter-and-examples\"},{\"text\":\"RFC 0018 — Plugin-scoped environment variables\",\"link\":\"/rfcs/0018-plugin-scoped-env\"},{\"text\":\"RFC 0019 — Test setup & seeding\",\"link\":\"/rfcs/0019-test-setup-and-seeding\"},{\"text\":\"RFC 0020 — Production dev-mode & diagnostics\",\"link\":\"/rfcs/0020-production-dev-mode\"},{\"text\":\"RFC 0021 — Platform roles & capabilities\",\"link\":\"/rfcs/0021-platform-roles-and-capabilities\"},{\"text\":\"RFC 0022 — Plugin-declared capabilities\",\"link\":\"/rfcs/0022-plugin-capabilities\"},{\"text\":\"RFC 0023 — SDK distribution & the plugin isolation boundary\",\"link\":\"/rfcs/0023-sdk-distribution\"},{\"text\":\"RFC 0024 — Plugin compatibility & versioning\",\"link\":\"/rfcs/0024-plugin-compatibility\"},{\"text\":\"RFC 0025 — Accessibility (WCAG 2.1 AA)\",\"link\":\"/rfcs/0025-accessibility\"},{\"text\":\"RFC 0026 — Non-Docker production deployment\",\"link\":\"/rfcs/0026-non-docker-deployment\"},{\"text\":\"RFC 0027 — White-labeling (tenant branding)\",\"link\":\"/rfcs/0027-white-labeling\"},{\"text\":\"RFC 0028 — Operator Fork Model & Upstream Sync\",\"link\":\"/rfcs/0028-operator-fork-model\"},{\"text\":\"RFC 0029 — Internationalization / Localization (i18n)\",\"link\":\"/rfcs/0029-internationalization\"},{\"text\":\"RFC 0030 — Privacy-First Analytics\",\"link\":\"/rfcs/0030-privacy-first-analytics\"},{\"text\":\"RFC 0031 — Email Template System\",\"link\":\"/rfcs/0031-email-templates\"},{\"text\":\"RFC 0032 — Instance Identity Rename\",\"link\":\"/rfcs/0032-instance-identity-rename\"},{\"text\":\"RFC 0033 — User data deletion (Right to erasure)\",\"link\":\"/rfcs/0033-user-data-deletion\"},{\"text\":\"RFC 0034 — Notification Center — Pluggable transport (pub/sub)\",\"link\":\"/rfcs/0034-notification-transport\"},{\"text\":\"RFC 0035 — Progressive user verification\",\"link\":\"/rfcs/0035-progressive-user-verification\"},{\"text\":\"RFC 0036 — Per-plugin database dialect selection\",\"link\":\"/rfcs/0036-per-plugin-dialect\"},{\"text\":\"RFC 0037 — VitePress public docs site and project landing page\",\"link\":\"/rfcs/0037-vitepress-docs-site\"},{\"text\":\"RFC 0038 — Desktop app shell (Tauri, macOS-first)\",\"link\":\"/rfcs/0038-desktop-app-shell\"},{\"text\":\"RFC 0039 — Instance identity — instanceId field and terminology cleanup\",\"link\":\"/rfcs/0039-instance-id-and-terminology\"},{\"text\":\"RFC 0040 — Sovereign Harness — AI assistant and orchestration layer\",\"link\":\"/rfcs/0040-sovereign-harness\"},{\"text\":\"RFC 0041 — User directory and member selection SDK\",\"link\":\"/rfcs/0041-user-directory\"},{\"text\":\"RFC 0042 — Public plugin page routes\",\"link\":\"/rfcs/0042-public-plugin-routes\"},{\"text\":\"RFC 0043 — Plugin secret vault\",\"link\":\"/rfcs/0043-plugin-secret-vault\"},{\"text\":\"RFC 0044 — Plugin file storage\",\"link\":\"/rfcs/0044-plugin-storage\"},{\"text\":\"RFC 0045 — Plugin events and realtime channels\",\"link\":\"/rfcs/0045-plugin-events\"},{\"text\":\"RFC 0046 — Plugin background jobs and schedules\",\"link\":\"/rfcs/0046-plugin-jobs\"},{\"text\":\"RFC 0047 — Plugin tool contracts\",\"link\":\"/rfcs/0047-plugin-tools\"},{\"text\":\"RFC 0048 — Messages and notification detail\",\"link\":\"/rfcs/0048-messages-and-notification-detail\"},{\"text\":\"RFC 0049 — Plugin external connections\",\"link\":\"/rfcs/0049-plugin-external-connections\"},{\"text\":\"RFC 0050 — Public plugin webhooks\",\"link\":\"/rfcs/0050-public-plugin-webhooks\"},{\"text\":\"RFC 0051 — Cross-plugin references and dependency discovery\",\"link\":\"/rfcs/0051-cross-plugin-references\"},{\"text\":\"RFC 0052 — Plugin portability hooks\",\"link\":\"/rfcs/0052-plugin-portability-hooks\"},{\"text\":\"RFC 0053 — Plugin flow handoffs\",\"link\":\"/rfcs/0053-plugin-flow-handoffs\"},{\"text\":\"RFC 0054 — Plugin-scoped roles and grants\",\"link\":\"/rfcs/0054-plugin-scoped-roles-and-grants\"},{\"text\":\"RFC 0055 — Sovereign Council — multi-model deliberation workspace\",\"link\":\"/rfcs/0055-sovereign-council\"},{\"text\":\"RFC 0056 — Sovereign Guide — first-run guide platform plugin\",\"link\":\"/rfcs/0056-sovereign-guide\"},{\"text\":\"RFC 0057 — Plugin external dependency resolution — automatic runtime dep hoisting on plugin add/remove\",\"link\":\"/rfcs/0057-plugin-dep-hoisting\"},{\"text\":\"RFC 0058 — Native mobile app shell (Capacitor)\",\"link\":\"/rfcs/0058-native-mobile-app-shell\"},{\"text\":\"RFC 0059 — Local visual regression testing\",\"link\":\"/rfcs/0059-local-visual-regression-testing\"},{\"text\":\"RFC 0060 — Client-side encryption core\",\"link\":\"/rfcs/0060-client-side-encryption-core\"},{\"text\":\"RFC 0061 — Sovereign Wallet platform plugin\",\"link\":\"/rfcs/0061-sovereign-wallet\"},{\"text\":\"RFC 0062 — Email delivery coverage\",\"link\":\"/rfcs/0062-email-delivery-coverage\"},{\"text\":\"RFC 0063 — Core Assistant, Jarvis UI, and Local Inference Sidecar\",\"link\":\"/rfcs/0063-core-assistant-jarvis\"},{\"text\":\"RFC 0064 — Git-backed operator backups\",\"link\":\"/rfcs/0064-git-backed-operator-backups\"},{\"text\":\"RFC 0065 — User groups and plugin access policy\",\"link\":\"/rfcs/0065-user-groups-plugin-access\"},{\"text\":\"RFC 0066 — Sovereign Chat companion app, peer identity, and P2P transport\",\"link\":\"/rfcs/0066-sovereign-chat-p2p-identity\"},{\"text\":\"RFC 0067 — Product-led docs site and instance directory\",\"link\":\"/rfcs/0067-product-led-docs-site\"},{\"text\":\"RFC 0068 — Export completeness hardening\",\"link\":\"/rfcs/0068-export-completeness-hardening\"},{\"text\":\"RFC 0069 — Bring-your-own database (per-user external Postgres)\",\"link\":\"/rfcs/0069-bring-your-own-database\"},{\"text\":\"RFC 0070 — Per-user capability grants\",\"link\":\"/rfcs/0070-per-user-capability-grants\"},{\"text\":\"RFC 0071 — SQLite at-rest encryption (opt-in, single-key)\",\"link\":\"/rfcs/0071-sqlite-at-rest-encryption\"},{\"text\":\"RFC 0072 — External OAuth/OIDC provider for non-plugin apps\",\"link\":\"/rfcs/0072-external-oauth-provider\"},{\"text\":\"RFC 0073 — Standalone usage of `@sovereignfs/ui` outside the plugin runtime\",\"link\":\"/rfcs/0073-standalone-ui-package\"},{\"text\":\"RFC 0074 — Offline-capable plugin routes\",\"link\":\"/rfcs/0074-offline-capable-plugins\"},{\"text\":\"RFC 0075 — Per-plugin mobile header/footer toggle\",\"link\":\"/rfcs/0075-mobile-chrome-toggle\"},{\"text\":\"RFC 0076 — Design system sizing alignment and new primitive components\",\"link\":\"/rfcs/0076-ds-sizing-alignment-and-new-primitives\"},{\"text\":\"RFC 0077 — Instance-level corner radius control\",\"link\":\"/rfcs/0077-instance-radius-control\"},{\"text\":\"RFC 0078 — Generic offline read+write for plugins\",\"link\":\"/rfcs/0078-offline-plugin-writes\"},{\"text\":\"RFC 0079 — Mobile PWA layout, overlay, and gesture consistency\",\"link\":\"/rfcs/0079-mobile-pwa-layout-overlay-gesture-consistency\"},{\"text\":\"RFC 0080 — Plugin surface model (`sdk.device.*` and `x-sovereign-surface`)\",\"link\":\"/rfcs/0080-plugin-surface-model\"},{\"text\":\"RFC 0081 — Per-plugin installable PWA\",\"link\":\"/rfcs/0081-per-plugin-installable-pwa\"},{\"text\":\"RFC 0082 — Focused plugin app shell (single-plugin native apps)\",\"link\":\"/rfcs/0082-focused-plugin-app-shell\"},{\"text\":\"RFC 0083 — Device bridge and capability contract\",\"link\":\"/rfcs/0083-device-bridge-capability-contract\"}]}],\"/sovereign-os/product/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"Product\",\"items\":[{\"text\":\"Target User\",\"link\":\"/sovereign-os/product/target-user\"},{\"text\":\"Core Use Cases\",\"link\":\"/sovereign-os/product/core-use-cases\"},{\"text\":\"Preview Scope\",\"link\":\"/sovereign-os/product/preview-scope\"},{\"text\":\"Terminology\",\"link\":\"/sovereign-os/product/terminology\"}]}],\"/sovereign-os/rfcs/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"RFCs\",\"items\":[{\"text\":\"RFC Index\",\"link\":\"/sovereign-os/rfcs/README\"},{\"text\":\"RFC-0010: Raspberry Pi Image Deployment\",\"link\":\"/sovereign-os/rfcs/0010-raspberry-pi-image-deployment\"},{\"text\":\"RFC-0014: Appliance Update System\",\"link\":\"/sovereign-os/rfcs/0014-appliance-update-system\"},{\"text\":\"RFC-0015: Update Discovery — Channel Metadata and Device-Side Checking\",\"link\":\"/sovereign-os/rfcs/0015-update-discovery\"}]}],\"/sovereign-os/adrs/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"ADRs\",\"items\":[{\"text\":\"ADR Index\",\"link\":\"/sovereign-os/adrs/README\"},{\"text\":\"ADR-0001: Phase 01 Pi-hole Appliance Architecture\",\"link\":\"/sovereign-os/adrs/0001-phase-01-appliance-architecture\"},{\"text\":\"ADR-0002: Separate Installation Images from Update Artifacts\",\"link\":\"/sovereign-os/adrs/0002-install-images-and-update-artifacts\"},{\"text\":\"ADR-0003: Preview Bootstrap Access\",\"link\":\"/sovereign-os/adrs/0003-preview-bootstrap-access\"},{\"text\":\"ADR-0004: Provider-Neutral Assistant and Web Search\",\"link\":\"/sovereign-os/adrs/0004-provider-neutral-assistant-and-web-search\"},{\"text\":\"ADR-0005: Sovereign Console Namespace and Health Boundary\",\"link\":\"/sovereign-os/adrs/0005-sovereign-console-and-health-boundary\"},{\"text\":\"ADR-0006: Production Update Signing-Key Custody\",\"link\":\"/sovereign-os/adrs/0006-production-signing-key-custody\"},{\"text\":\"ADR-0007: Sovereign Console Authentication\",\"link\":\"/sovereign-os/adrs/0007-console-authentication\"},{\"text\":\"ADR-0008: Console Privileged Action Invocation\",\"link\":\"/sovereign-os/adrs/0008-console-privileged-action-invocation\"},{\"text\":\"ADR-0009: Console-Triggered Update Install\",\"link\":\"/sovereign-os/adrs/0009-console-triggered-install\"}]}],\"/sovereign-os/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"Sovereign OS\",\"items\":[{\"text\":\"Concept\",\"link\":\"/sovereign-os/concept\"},{\"text\":\"Product\",\"link\":\"/sovereign-os/product/target-user\"},{\"text\":\"Roadmap\",\"link\":\"/sovereign-os/roadmap\"},{\"text\":\"RFCs\",\"link\":\"/sovereign-os/rfcs/README\"},{\"text\":\"ADRs\",\"link\":\"/sovereign-os/adrs/README\"}]}],\"/sovereign-edge/research/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"Research\",\"items\":[{\"text\":\"Research Index\",\"link\":\"/sovereign-edge/research/README\"},{\"text\":\"Research 0001 — Sovereign Edge: concept, positioning, and connector architecture\",\"link\":\"/sovereign-edge/research/0001-concept-and-connector-architecture\"},{\"text\":\"Research 0002 — React Native framework choice: Expo vs. Community CLI\",\"link\":\"/sovereign-edge/research/0002-react-native-framework-choice\"},{\"text\":\"Research 0003 — Model download verification: hashing strategy\",\"link\":\"/sovereign-edge/research/0003-model-verification-hashing\"}]}],\"/sovereign-edge/epics/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"Epics\",\"items\":[{\"text\":\"Epics Overview\",\"link\":\"/sovereign-edge/epics/README\"},{\"text\":\"0 — Infrastructure\",\"link\":\"/sovereign-edge/epics/infrastructure\"},{\"text\":\"1 — Core Inference & Chat\",\"link\":\"/sovereign-edge/epics/core-inference-chat\"},{\"text\":\"2 — Connector Framework\",\"link\":\"/sovereign-edge/epics/connector-framework\"},{\"text\":\"3 — Search Connector\",\"link\":\"/sovereign-edge/epics/search-connector\"},{\"text\":\"4 — Sovereign Tasks Connector\",\"link\":\"/sovereign-edge/epics/sovereign-tasks-connector\"},{\"text\":\"5 — Connector Store & SDK\",\"link\":\"/sovereign-edge/epics/connector-store-sdk\"},{\"text\":\"6 — Monetization\",\"link\":\"/sovereign-edge/epics/monetization\"},{\"text\":\"7 — Design System & Branding\",\"link\":\"/sovereign-edge/epics/design-system\"},{\"text\":\"8 — Mobile App Shell\",\"link\":\"/sovereign-edge/epics/mobile-app-shell\"},{\"text\":\"9 — Desktop App\",\"link\":\"/sovereign-edge/epics/desktop-app\"}]}],\"/sovereign-edge/\":[{\"text\":\"← Sovereign\",\"link\":\"/\"},{\"text\":\"Sovereign Edge\",\"items\":[{\"text\":\"Concept\",\"link\":\"/sovereign-edge/concept\"},{\"text\":\"Roadmap\",\"link\":\"/sovereign-edge/roadmap\"},{\"text\":\"Development Workflow\",\"link\":\"/sovereign-edge/development-workflow\"},{\"text\":\"Research\",\"link\":\"/sovereign-edge/research/README\"},{\"text\":\"Epics\",\"link\":\"/sovereign-edge/epics/README\"}]}],\"/\":[{\"text\":\"Operator Guides\",\"items\":[{\"text\":\"Self-Hosting\",\"link\":\"/self-hosting\"},{\"text\":\"Upgrade Guide\",\"link\":\"/upgrade\"},{\"text\":\"Troubleshooting\",\"link\":\"/troubleshooting\"}]},{\"text\":\"App Developer Guides\",\"items\":[{\"text\":\"Overview\",\"link\":\"/plugin-development\"},{\"text\":\"SDK Stability\",\"link\":\"/sdk-stability\"},{\"text\":\"Plugin Database\",\"link\":\"/plugin-database\"},{\"text\":\"Design System\",\"link\":\"/design-system\"}]},{\"text\":\"Architecture & Security\",\"items\":[{\"text\":\"Architecture\",\"link\":\"/architecture\"},{\"text\":\"Security\",\"link\":\"/security\"},{\"text\":\"Repository Map\",\"link\":\"/repositories\"}]},{\"text\":\"Core Plugins\",\"items\":[{\"text\":\"Console\",\"link\":\"/plugins/console\"},{\"text\":\"Launcher\",\"link\":\"/plugins/launcher\"},{\"text\":\"Account\",\"link\":\"/plugins/account\"}]},{\"text\":\"Contributor Guides\",\"items\":[{\"text\":\"Documentation Structure\",\"link\":\"/documentation-structure\"},{\"text\":\"Development Workflow\",\"link\":\"/development-workflow\"},{\"text\":\"Agent-First Documentation\",\"link\":\"/agent-first-documentation\"},{\"text\":\"Architecture Rules\",\"link\":\"/architecture-rules\"},{\"text\":\"Testing E2E\",\"link\":\"/testing-e2e\"},{\"text\":\"PWA Device Testing\",\"link\":\"/pwa-real-device-testing\"}]},{\"text\":\"RFCs\",\"collapsed\":true,\"items\":[{\"text\":\"RFC Index\",\"link\":\"/rfcs/README\"}]}]},\"search\":{\"provider\":\"local\"},\"socialLinks\":[{\"icon\":\"github\",\"link\":\"https://github.com/sovereignfs/sovereign\"}],\"footer\":{\"message\":\"Open source under AGPL-3.0. Each Sovereign instance is independently operated.\",\"copyright\":\"Sovereign\"}},\"locales\":{},\"scrollOffset\":134,\"cleanUrls\":false}");</script>
</body>
</html>