@x47base/pocketbase-addon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. package/web/static_test.go +48 -0
@@ -0,0 +1,116 @@
1
+ package security
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "io"
7
+ "maps"
8
+ "os"
9
+ "path/filepath"
10
+ "time"
11
+ )
12
+
13
+ type checkpoint struct {
14
+ Version int `json:"version"`
15
+ Revision uint64 `json:"revision"`
16
+ Policy Config `json:"policy"`
17
+ Incidents []Incident `json:"incidents"`
18
+ Actions map[string]Action `json:"actions"`
19
+ NextID uint64 `json:"nextId"`
20
+ }
21
+
22
+ func (s *Service) loadState() {
23
+ if s.config.StatePath == "" {
24
+ return
25
+ }
26
+ file, err := os.Open(s.config.StatePath)
27
+ if os.IsNotExist(err) {
28
+ return
29
+ }
30
+ if err != nil {
31
+ s.storageError = true
32
+ return
33
+ }
34
+ defer file.Close()
35
+ raw, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
36
+ if err != nil || len(raw) > 1<<20 {
37
+ s.storageError = true
38
+ return
39
+ }
40
+ var state checkpoint
41
+ if json.Unmarshal(raw, &state) != nil || state.Version != 1 || state.Revision == 0 || len(state.Incidents) > 256 || len(state.Actions) > 6 || state.Policy.Validate() != nil {
42
+ s.storageError = true
43
+ return
44
+ }
45
+ for _, incident := range state.Incidents {
46
+ if !validFamily(incident.Family) || incident.ID > state.NextID || len(incident.Phase) > 32 {
47
+ s.storageError = true
48
+ return
49
+ }
50
+ }
51
+ for family, action := range state.Actions {
52
+ if !validFamily(family) || family != action.Family || len(action.Reason) > 200 {
53
+ s.storageError = true
54
+ return
55
+ }
56
+ if !action.ExpiresAt.After(time.Now()) || action.ExpiresAt.After(time.Now().Add(15*time.Minute)) {
57
+ delete(state.Actions, family)
58
+ }
59
+ }
60
+ state.Policy.OperatorPeers = s.config.OperatorPeers
61
+ state.Policy.StatePath = s.config.StatePath
62
+ state.Policy.Mode = s.config.Mode
63
+ s.config = state.Policy
64
+ s.revision = state.Revision
65
+ s.incidents = state.Incidents
66
+ s.actions = state.Actions
67
+ s.nextID = state.NextID
68
+ if s.actions == nil {
69
+ s.actions = map[string]Action{}
70
+ }
71
+ }
72
+ func (s *Service) persist() {
73
+ s.journalMu.Lock()
74
+ defer s.journalMu.Unlock()
75
+ s.mu.Lock()
76
+ path := s.config.StatePath
77
+ state := checkpoint{Version: 1, Revision: s.revision, Policy: cloneConfig(s.config), Incidents: append([]Incident{}, s.incidents...), Actions: maps.Clone(s.actions), NextID: s.nextID}
78
+ s.mu.Unlock()
79
+ if path == "" {
80
+ return
81
+ }
82
+ raw, err := json.Marshal(state)
83
+ if err == nil && len(raw) > 1<<20 {
84
+ err = errors.New("state exceeds size limit")
85
+ }
86
+ if err == nil {
87
+ err = os.MkdirAll(filepath.Dir(path), 0700)
88
+ }
89
+ if err == nil {
90
+ var file *os.File
91
+ file, err = os.CreateTemp(filepath.Dir(path), ".security-state-*")
92
+ if err == nil {
93
+ defer os.Remove(file.Name())
94
+ defer file.Close()
95
+ if _, err = file.Write(raw); err == nil {
96
+ err = file.Sync()
97
+ }
98
+ if closeErr := file.Close(); err == nil {
99
+ err = closeErr
100
+ }
101
+ if err == nil {
102
+ err = os.Rename(file.Name(), path)
103
+ }
104
+ }
105
+ }
106
+ s.mu.Lock()
107
+ s.storageError = err != nil
108
+ s.mu.Unlock()
109
+ }
110
+ func validFamily(family string) bool {
111
+ switch family {
112
+ case "auth", "reads", "writes", "files", "realtime", "other":
113
+ return true
114
+ }
115
+ return false
116
+ }
@@ -0,0 +1,4 @@
1
+ .pb-security{padding:28px 32px;overflow:auto;display:block!important;--sec-bg:light-dark(#fff,#22252c);--sec-text:light-dark(#222832,#e7eaf0);--sec-muted:light-dark(#616b79,#b2bac7);--sec-line:light-dark(#dce1e7,#434956);--sec-soft:light-dark(#f5f7fa,#2c3039);color:var(--sec-text)}
2
+ .pb-security *{box-sizing:border-box}.pb-security h1{font-size:28px;margin:2px 0 8px}.pb-security h2{font-size:18px;margin:0 0 12px}.pb-security p{line-height:1.5}.pb-security .sec-heading{display:flex;justify-content:space-between;gap:24px;align-items:center}.pb-security .sec-eyebrow{font-size:11px;letter-spacing:.13em;font-weight:600;color:var(--sec-muted);margin:0}.pb-security .sec-refresh{display:flex;flex-direction:column;gap:8px;align-items:end;white-space:nowrap}.pb-security [data-updated],.pb-security .sec-muted,.pb-security small{color:var(--sec-muted);font-size:13px}.pb-security .sec-tabs{display:flex;gap:8px;border-bottom:1px solid var(--sec-line);margin:20px 0}.pb-security .sec-tab{padding:12px 18px;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;border-bottom:3px solid transparent}.pb-security .sec-tab[aria-current=page]{border-bottom-color:light-dark(#315cbf,#8fb4ff);font-weight:600}.pb-security button:focus-visible,.pb-security input:focus-visible,.pb-security select:focus-visible,.pb-security textarea:focus-visible,.pb-security summary:focus-visible{outline:3px solid #749cfa;outline-offset:3px}.pb-security .sec-notice{padding:12px 16px;background:var(--sec-soft);border-radius:8px;margin:12px 0}.pb-security .sec-error{background:light-dark(#fff1ef,#482c2b);color:light-dark(#982d22,#ffc1b9)}.pb-security .sec-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(165px,1fr));gap:16px;margin-bottom:20px}.pb-security .sec-stat{display:flex;flex-direction:column;gap:8px;padding:18px;border:1px solid var(--sec-line);border-radius:10px;background:var(--sec-bg)}.pb-security .sec-stat>span{font-size:13px;color:var(--sec-muted)}.pb-security .sec-stat strong{font-size:26px;font-weight:600;text-transform:capitalize}.pb-security .sec-columns{display:grid;grid-template-columns:1.4fr 1fr;gap:20px}.pb-security .sec-panel{padding:22px;background:var(--sec-bg);border:1px solid var(--sec-line);border-radius:10px;margin-bottom:20px}.pb-security .sec-chart{height:150px;display:flex;gap:3px;align-items:end;border-bottom:1px solid var(--sec-line);margin:20px 0}.pb-security .sec-bar{min-width:2px;max-width:28px;flex:1;background:#557bc5;display:flex;align-items:end;border-radius:3px 3px 0 0}.pb-security .sec-bar span{width:100%;background:#c79030}.pb-security .sec-facts{display:grid;grid-template-columns:auto 1fr;gap:12px 18px;font-size:14px}.pb-security dt{color:var(--sec-muted)}.pb-security dd{margin:0;overflow-wrap:anywhere}.pb-security .sec-context{color:var(--sec-muted);font-size:13px}.pb-security .sec-filters{display:flex;gap:18px;margin-bottom:20px}.pb-security .sec-field{display:flex;flex-direction:column;gap:8px;flex:1;min-width:0;margin:0 0 18px;font-size:14px}.pb-security .sec-field>span{font-weight:600}.pb-security input,.pb-security select,.pb-security textarea{display:block;width:100%;height:auto;min-height:42px;padding:10px 12px;border:1px solid var(--sec-line);background:var(--sec-bg);color:var(--sec-text);border-radius:6px;font:inherit;outline-offset:2px;appearance:auto}.pb-security .sec-empty{color:var(--sec-muted);padding-top:12px;padding-bottom:12px}.pb-security .sec-incident-heading{display:flex;gap:16px;align-items:start;justify-content:space-between}.pb-security .sec-badge{display:inline-block;font-size:12px;font-weight:600;padding:5px 10px;border-radius:30px;background:var(--sec-soft);white-space:nowrap}.pb-security .sec-active{background:light-dark(#fff0cf,#493c25);color:light-dark(#78510a,#ffd383)}.pb-security .sec-recovering{background:light-dark(#e7efff,#293a58);color:light-dark(#2c559c,#a9c8ff)}.pb-security .sec-resolved{background:light-dark(#e4f3e9,#263d2e);color:light-dark(#226c3f,#a4dfb8)}.pb-security details{margin:14px 0 20px}.pb-security summary{cursor:pointer;font-weight:600;padding:8px 0}.pb-security .sec-action{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 0;border-bottom:1px solid var(--sec-line)}.pb-security .sec-action>span:nth-child(2){flex:1;overflow-wrap:anywhere}.pb-security .sec-action-form{display:grid;grid-template-columns:1fr .7fr 1.4fr;gap:16px;align-items:start;margin-top:20px}.pb-security .sec-action-form>button{grid-column:1/-1;justify-self:start}.pb-security .sec-policy-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px}.pb-security .sec-buttons{display:flex;gap:12px;flex-wrap:wrap}.pb-security .btn{max-width:100%;white-space:normal}
3
+ @media(max-width:760px){.pb-security{padding:20px 16px}.pb-security .sec-heading{display:block}.pb-security .sec-refresh{align-items:start}.pb-security .sec-columns,.pb-security .sec-action-form,.pb-security .sec-policy-fields{grid-template-columns:1fr}.pb-security .sec-tabs{gap:0}.pb-security .sec-tab{padding:12px}.pb-security .sec-filters{display:block}.pb-security .sec-stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.pb-security .sec-stat{padding:12px}.pb-security .sec-stat strong{font-size:22px}.pb-security .sec-panel{padding:16px}.pb-security .sec-facts{grid-template-columns:1fr;gap:6px}.pb-security dd{margin-bottom:10px}.pb-security input,.pb-security select,.pb-security textarea{font-size:16px}}
4
+ .pb-security .sec-field input[type=checkbox]{appearance:auto;width:20px;height:20px;min-height:20px;margin:4px 0;justify-self:start}.pb-security .sec-field input[type=file]{height:auto;min-height:44px;padding:8px}.pb-security form>.btn{margin:4px 10px 12px 0}
@@ -0,0 +1,83 @@
1
+ import { families, familyLabels, filterIncidents, percent, rate, timestamp, sessionRequest } from './model.js';
2
+
3
+ const fields = [
4
+ ['requestsPerSecond','Global requests / second','Total admission rate before authentication.'],
5
+ ['burst','Global burst','Short bursts allowed across the instance.'],
6
+ ['identityPerSecond','Requests / identity / second','Verified users have separate budgets; anonymous clients share a source budget.'],
7
+ ['identityBurst','Identity burst','Short burst allowed for one identity.'],
8
+ ['maxConcurrent','Concurrent requests','Total in-flight application requests.'],
9
+ ['maxWrites','Concurrent write units','Batch operations reserve additional write units.'],
10
+ ['maxFiles','Concurrent file requests','Capacity reserved for file traffic.'],
11
+ ['maxRealtime','Realtime clients','Maximum tracked realtime connections.'],
12
+ ['maxSubscriptions','Subscriptions / client','Maximum subscriptions for one client.'],
13
+ ['maxSubscriptionsTotal','Total subscriptions','Aggregate tracked subscription capacity.'],
14
+ ['maxIdentities','Tracked identities','Excess identities share a bounded overflow bucket.'],
15
+ ];
16
+ function el(tag, text, className) { const node = document.createElement(tag); if (text != null) node.textContent = text; if(className) node.className=className; return node; }
17
+ function button(label, action, className='btn secondary') { const node=el('button',label,className);node.type='button';node.onclick=action;return node; }
18
+ function badge(label, state) { return el('span',label,'sec-badge sec-'+state); }
19
+ function labelField(label, input, hint) { const wrap=el('label',null,'sec-field');wrap.append(el('span',label),input);if(hint)wrap.append(el('small',hint));return wrap; }
20
+ function select(options, value='') { const node=el('select');for(const [key,label] of options){const option=el('option',label);option.value=key;node.append(option);}node.value=options.some(([key])=>key===value)?value:(options[0]?.[0]||'');return node; }
21
+
22
+ export function securityPage(app) {
23
+ let destroy=()=>{};
24
+ return t.div({className:'page pb-security',onmount:root=>{destroy=mount(root,app);},onunmount:()=>destroy()});
25
+ }
26
+ function mount(root, app) {
27
+ app.store.title='Security';
28
+ let view='overview', phase='', family='', status=null, incidents=[], draft=null, revision='', dirty=false, timer, busy=false, mutation=false;
29
+ let closed=false, sessionGone=false, lastRefresh=null, operatorNotice=false;
30
+ const session=()=>app.pb.authStore.token;
31
+ const client=sessionRequest((url,options)=>app.pb.send(url,options),session,()=>app.pb.authStore.clear());
32
+ root.innerHTML=`<div class="sec-heading"><div><p class="sec-eyebrow">INSTANCE PROTECTION</p><h1>Security</h1><p>Understand unusual activity and control how this instance handles traffic.</p></div><div class="sec-refresh"><button type="button" class="btn secondary" data-refresh>Refresh</button><span data-updated></span></div></div><div class="sec-notice" role="status" aria-live="polite" data-notice>Loading security data…</div><nav class="sec-tabs" aria-label="Security views"></nav><div data-content></div>`;
33
+ const content=root.querySelector('[data-content]'), notice=root.querySelector('[data-notice]'), updated=root.querySelector('[data-updated]');
34
+ const refreshButton=root.querySelector('[data-refresh]');
35
+ function message(text,error=false){if(closed)return;notice.textContent=text;notice.className='sec-notice'+(error?' sec-error':'');notice.hidden=!text;}
36
+ function checkSession(){if(!client.valid()){sessionGone=true;client.close();clearTimeout(timer);content.replaceChildren();message('Your session ended. Sign in to view security data.',true);return false;}return true;}
37
+ const unsubscribe=app.pb.authStore.onChange(()=>{if(!checkSession()) window.location.hash='#/login';});
38
+ function failure(error){if(closed||!checkSession())return;message(error.status===409?'The policy changed in another session. Your draft is preserved. Reload the current policy before saving.':error.status===403?'This connection is outside the management network. Ask the operator to configure access; dashboard login does not bypass this restriction.':error.message||'Security data could not be refreshed. Try again.',true);}
39
+ const tabs=root.querySelector('.sec-tabs');
40
+ function renderTabs(){tabs.replaceChildren();for(const [id,label] of [['overview','Overview'],['incidents','Incidents'],['protection','Protection']]){const b=button(label,()=>{view=id;render();},'sec-tab');b.setAttribute('aria-current',view===id?'page':'false');tabs.append(b);}}
41
+ function stats(parent, rows){const grid=el('div',null,'sec-stats');for(const [label,value,note] of rows){const card=el('section',null,'sec-stat');card.append(el('span',label),el('strong',String(value)),el('small',note));grid.append(card);}parent.append(grid);}
42
+ function render(){if(closed||!checkSession())return;renderTabs();content.replaceChildren();if(!status){content.append(el('p','Security data is not available yet.'));return;}if(view==='overview')overview();if(view==='incidents')incidentView();if(view==='protection')protection();}
43
+ function overview(){
44
+ const active=incidents.filter(i=>i.phase!=='resolved').length, last=status.traffic?.at(-1);
45
+ stats(content,[['Protection mode',status.mode,status.mode==='enforce'?'Configured limits can reject requests.':status.mode==='observe'?'Limits are observed without blocking.':'Admission protection and detection are disabled.'],['Open incidents',active,'Active or recovering suspected patterns.'],['Recent traffic',last?rate(last).toFixed(1)+' req/s':'Waiting','Last completed 10-second window.'],['Flagged requests',status.rejected,status.mode==='observe'?'Would be limited; requests still proceed.':'Admission decisions since this process started.']]);
46
+ const grid=el('div',null,'sec-columns');const traffic=el('section',null,'sec-panel');traffic.append(el('h2','Traffic over time'),el('p','Last 10 minutes · each bar is a 10-second window.','sec-muted'));
47
+ if(status.traffic?.length){const chart=el('div',null,'sec-chart');chart.setAttribute('role','img');chart.setAttribute('aria-label','Request volume for '+status.traffic.length+' recent ten-second windows');const peak=Math.max(1,...status.traffic.map(w=>w.count));for(const w of status.traffic){const bar=el('div',null,'sec-bar');bar.style.height=Math.max(2,100*w.count/peak)+'%';bar.title=timestamp(w.at)+': '+w.count+' requests, '+w.rejected+' flagged';const limited=el('span');limited.style.height=percent(w.rejected,w.count)+'%';bar.append(limited);chart.append(bar);}traffic.append(chart,el('p',timestamp(status.traffic[0].at)+' → '+timestamp(last.at)+' · Peak '+peak+' requests / window.','sec-muted'),el('p','Blue: requests · amber: flagged requests.','sec-muted'));if(last)traffic.append(el('p',`${last.count} requests · ${last.rejected} flagged · ${last.failures} failed · ${last.slow} slow in the latest window.`));}
48
+ else traffic.append(el('p',status.mode==='disabled'?'Enable observation to collect traffic windows.':'Waiting for the first 10-second observation window.','sec-empty'));
49
+ const health=el('section',null,'sec-panel');health.append(el('h2','Operational context'));const dl=el('dl',null,'sec-facts');for(const [key,value] of [['Detector','Heuristic patterns; no automatic blocks'],['Last evaluation',timestamp(status.lastEvaluated)],['Process started',timestamp(status.startedAt)],['Restart checkpoint',status.storageDegraded?'Write/read failure — recent state may be lost':status.checkpointEnabled?'Enabled (best effort)':'Not configured'],['Subscriptions',String(status.subscriptions)],['Active requests',Number(status.active?.ordinary||0)+Number(status.active?.realtime||0)+Number(status.active?.management||0)]])dl.append(el('dt',key),el('dd',String(value)));health.append(dl);grid.append(traffic,health);content.append(grid);
50
+ content.append(el('p','An incident is a suspected pattern, not proof of an attacker. Legitimate bursts, shared-source traffic and outages can produce the same signals.','sec-context'));
51
+ content.append(button('Review incidents',()=>{view='incidents';render();}));
52
+ }
53
+ function incidentView(){
54
+ const filters=el('div',null,'sec-filters');const phaseInput=select([['','All states'],['active','Active'],['recovering','Recovering'],['resolved','Resolved'],['unreviewed','Not acknowledged']],phase);phaseInput.onchange=()=>{phase=phaseInput.value;render();};const familyInput=select([['','All traffic'],...families.map(f=>[f,familyLabels[f]])],family);familyInput.onchange=()=>{family=familyInput.value;render();};filters.append(labelField('Incident state',phaseInput),labelField('Traffic family',familyInput));content.append(filters);
55
+ const rows=filterIncidents(incidents,phase,family);
56
+ if(!rows.length){const empty=el('section',null,'sec-panel sec-empty');empty.append(el('h2',incidents.length?'No matching incidents':'No incidents recorded'),el('p',incidents.length?'Change the filters to see other activity.':'Detection needs three consecutive suspicious 10-second windows. Short tests may not produce an incident.'));content.append(empty);return;}
57
+ const list=el('div',null,'sec-incidents');for(const item of rows){const card=el('article',null,'sec-panel');const heading=el('div',null,'sec-incident-heading');heading.append(el('h2',(familyLabels[item.family]||item.family)+' · #'+item.id),badge(item.phase,item.phase));card.append(heading,el('p','First seen '+timestamp(item.firstSeen)+' · Latest evidence '+timestamp(item.lastSeen),'sec-muted'));const o=item.evidence||{};stats(card,[['Requests',o.count||0,'Latest evidence window'],['Flagged',percent(o.rejected,o.count)+'%',(o.rejected||0)+' admission decisions'],['Failures',o.failures||0,(o.slow||0)+' slow requests']]);const details=el('details');details.append(el('summary','How to interpret this incident'),el('p','Compare traffic with client releases, database health and provider metrics. Acknowledging records review; it does not block traffic or resolve the detector state.'));card.append(details);const ack=button(item.acknowledged?'Acknowledged':'Acknowledge',()=>mutate(async()=>{await client.request('incidents/'+item.id+'/acknowledge',{method:'POST'});},'Incident acknowledged.'));ack.disabled=item.acknowledged||mutation;card.append(ack);list.append(card);}content.append(list);
58
+ }
59
+ function renderActions(list=content.querySelector('[data-sec-actions]')) {
60
+ if(!list||!status)return;list.replaceChildren();
61
+ for(const action of Object.values(status.actions||{})){const row=el('div',null,'sec-action');row.append(badge(familyLabels[action.family]||action.family,'active'),el('span',action.reason+' · Expires '+timestamp(action.expiresAt)),button('Cancel block',()=>mutate(()=>client.request('actions/'+action.family,{method:'DELETE'}),'Block cancelled.')));list.append(row);}
62
+ if(!Object.keys(status.actions||{}).length)list.append(el('p','No temporary blocks are configured.','sec-empty'));
63
+ }
64
+ function protection(){
65
+ const actions=el('section',null,'sec-panel');actions.append(el('h2','Temporary protection'),el('p','Family blocks reject all matching traffic, including legitimate users. They apply only in enforce mode.','sec-muted'));
66
+ const liveActions=el('div');liveActions.dataset.secActions='';actions.append(liveActions);renderActions(liveActions);
67
+ const form=el('form',null,'sec-action-form');const target=select(families.map(f=>[f,familyLabels[f]]));const minutes=el('input');minutes.type='number';minutes.min='1';minutes.max='15';minutes.value='1';minutes.required=true;const reason=el('input');reason.type='text';reason.maxLength=200;reason.required=true;form.append(labelField('Traffic to block',target),labelField('Duration (minutes)',minutes),labelField('Reason',reason));const submit=el('button','Apply temporary block','btn danger');submit.type='submit';submit.disabled=mutation;form.append(submit);form.onsubmit=e=>{e.preventDefault();mutate(()=>client.request('actions',{method:'POST',body:{family:target.value,reason:reason.value,expiresAt:new Date(Date.now()+Number(minutes.value)*60000).toISOString()}}),'Temporary block configured.');};actions.append(form);content.append(actions);
68
+ if(!draft){content.append(el('p','Policy is unavailable. Refresh to try again.'));return;}
69
+ const panel=el('section',null,'sec-panel');panel.append(el('h2','Protection policy'),el('p','Changes use revision '+revision+'. Unsaved settings are preserved while traffic refreshes.','sec-muted'));const policy=el('form');const mode=select([['disabled','Disabled'],['observe','Observe — measure only'],['enforce','Enforce — reject over budget']],draft.mode);mode.onchange=()=>{draft.mode=mode.value;dirty=true;};policy.append(labelField('Mode',mode));const fieldsGrid=el('div',null,'sec-policy-fields');for(const [key,label,hint] of fields){const input=el('input');input.type='number';input.required=true;input.min=key.includes('PerSecond')?'0.001':'1';input.max=key.includes('PerSecond')?'100000':'50000';input.step=key.includes('PerSecond')?'any':'1';input.value=draft[key];input.oninput=()=>{draft[key]=Number(input.value);dirty=true;};fieldsGrid.append(labelField(label,input,hint));}policy.append(fieldsGrid);
70
+ const networks=el('details');networks.append(el('summary','Advanced: proxy and management networks'));for(const [key,label] of [['trustedPeers','Trusted immediate proxy CIDRs'],['managementPeers','Management peer CIDRs']]){const input=el('textarea');input.rows=3;input.value=(draft[key]||[]).join('\n');input.oninput=()=>{draft[key]=input.value.split(/[\n,]/).map(v=>v.trim()).filter(Boolean);dirty=true;};networks.append(labelField(label,input,'One CIDR per line. Incorrect changes can block operator access.'));}policy.append(networks);const buttons=el('div',null,'sec-buttons');const save=el('button','Save policy','btn');save.type='submit';save.disabled=mutation;buttons.append(save,button('Discard draft and reload',()=>mutate(async()=>{const snapshot=await getPolicy();draft=snapshot.policy;revision=snapshot.revision;dirty=false;},'Current policy loaded.')));policy.append(buttons);policy.onsubmit=e=>{e.preventDefault();mutate(async()=>{await client.request('policy',{method:'PUT',headers:{'If-Match':revision},body:draft});const snapshot=await getPolicy();draft=snapshot.policy;revision=snapshot.revision;dirty=false;},'Policy saved.');};panel.append(policy);content.append(panel);
71
+ }
72
+ async function getPolicy(){return client.request('policy/snapshot');}
73
+ async function load(){
74
+ const nextStatus=await client.request('status');let all=[],after=0;
75
+ for(let page=0;page<3;page++){const response=await client.request('incidents?limit=100&after='+after);all.push(...response.items);after=response.nextCursor;if(!after)break;}
76
+ if(!draft){const snapshot=await getPolicy();draft=snapshot.policy;revision=snapshot.revision;}
77
+ if(!checkSession())return;status=nextStatus;incidents=all;lastRefresh=new Date();updated.textContent='Updated '+lastRefresh.toLocaleTimeString();
78
+ }
79
+ async function refresh(manual=false){if(busy||mutation||closed||!checkSession())return;busy=true;refreshButton.disabled=true;try{const firstLoad=!status;await load();if(firstLoad||(view!=='protection'&&!content.contains(document.activeElement))||(manual&&!dirty))render();else if(view==='protection')renderActions();if(!operatorNotice)message('');}catch(error){failure(error);}finally{busy=false;refreshButton.disabled=false;if(!closed&&!sessionGone)timer=setTimeout(()=>refresh(),5000);}}
80
+ async function mutate(operation,success){if(mutation||closed||!checkSession())return;if(busy){message('A refresh is finishing. Please try the action again.');return;}mutation=true;operatorNotice=true;content.inert=true;content.setAttribute('aria-busy','true');clearTimeout(timer);try{await operation();await load();render();message(success);}catch(error){failure(error);}finally{mutation=false;content.inert=false;content.removeAttribute('aria-busy');if(!closed&&!sessionGone)render();if(!closed&&!sessionGone)timer=setTimeout(()=>refresh(),5000);}}
81
+ refreshButton.onclick=()=>{clearTimeout(timer);refresh(true);};renderTabs();refresh();
82
+ return ()=>{closed=true;clearTimeout(timer);unsubscribe();client.close();root.replaceChildren();};
83
+ }
@@ -0,0 +1,15 @@
1
+ if (!app.routes?.superuserOnly || !app.store?.headerLinks || !app.pb?.send) {
2
+ throw new Error('Security adapter: unsupported PocketBase admin extension API');
3
+ }
4
+ const base = app.pb.buildURL('/_/extensions/security/');
5
+ const stylesheet = document.createElement('link');
6
+ stylesheet.rel = 'stylesheet';
7
+ stylesheet.href = base + 'dashboard.css';
8
+ document.head.append(stylesheet);
9
+ app.routes.superuserOnly('#/security', async () => {
10
+ const { securityPage } = await import(base + 'dashboard.js');
11
+ return securityPage(app);
12
+ });
13
+ if (!app.store.headerLinks.some(link => link.href === '#/security')) {
14
+ app.store.headerLinks.push({ href: '#/security', label: 'Security', icon: 'ri-shield-check-line' });
15
+ }
@@ -0,0 +1,32 @@
1
+ export const families = ['auth', 'reads', 'writes', 'files', 'realtime', 'other'];
2
+ export const familyLabels = { auth: 'Authentication', reads: 'Record reads', writes: 'Record writes', files: 'Files', realtime: 'Realtime', other: 'Other / health' };
3
+ export function rate(window) { return Number(window?.count || 0) / 10; }
4
+ export function percent(part, total) { return total > 0 ? Math.round(100 * part / total) : 0; }
5
+ export function filterIncidents(items, phase, family) {
6
+ return items.filter(i => (!phase || (phase === 'unreviewed' ? !i.acknowledged : i.phase === phase)) && (!family || i.family === family)).sort((a,b) => b.id-a.id);
7
+ }
8
+ export function timestamp(value) {
9
+ if (!value || String(value).startsWith('0001-')) return 'Not available yet';
10
+ const date = new Date(value);
11
+ return Number.isNaN(date.valueOf()) ? 'Not available' : date.toLocaleString();
12
+ }
13
+ export function sessionRequest(send, getSession, onExpired = () => {}) {
14
+ let alive = true;
15
+ const initial = getSession();
16
+ const abort = new AbortController();
17
+ return {
18
+ async request(path, options = {}) {
19
+ if (!alive || getSession() !== initial) throw new Error('Session changed. Reopen Security.');
20
+ try {
21
+ const result = await send('/api/security/' + path, { ...options, signal: abort.signal, requestKey: null });
22
+ if (!alive || getSession() !== initial) throw new Error('Session changed. Reopen Security.');
23
+ return result;
24
+ } catch (error) {
25
+ if (alive && error.status === 401) onExpired();
26
+ throw error;
27
+ }
28
+ },
29
+ close() { alive = false; abort.abort(); },
30
+ valid() { return alive && getSession() === initial; },
31
+ };
32
+ }
@@ -0,0 +1,25 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {sessionRequest,filterIncidents,percent,rate} from './model.js';
4
+ test('filters retain acknowledged active incidents unless unreviewed selected',()=>{
5
+ const rows=[{id:1,phase:'active',family:'auth',acknowledged:true},{id:2,phase:'resolved',family:'reads',acknowledged:false}];
6
+ assert.deepEqual(filterIncidents(rows,'active','auth').map(i=>i.id),[1]);
7
+ assert.deepEqual(filterIncidents(rows,'unreviewed','').map(i=>i.id),[2]);
8
+ assert.equal(percent(0,0),0);assert.equal(rate({count:120}),12);
9
+ });
10
+ test('logout discards a pending response',async()=>{
11
+ let session='one',resolve;
12
+ const client=sessionRequest(()=>new Promise(r=>resolve=r),()=>session);
13
+ const pending=client.request('status');session='two';resolve({secret:'old session'});
14
+ await assert.rejects(pending,/Session changed/);
15
+ });
16
+ test('unmount aborts and rejects late responses',async()=>{
17
+ let resolve,signal;
18
+ const client=sessionRequest((_,opts)=>{signal=opts.signal;return new Promise(r=>resolve=r);},()=> 'same');
19
+ const pending=client.request('status');client.close();assert.equal(signal.aborted,true);resolve({});await assert.rejects(pending,/Session changed/);
20
+ });
21
+ test('authentication failure invokes login handling',async()=>{
22
+ let expired=false;
23
+ const client=sessionRequest(async()=>{throw Object.assign(new Error('expired'),{status:401});},()=> 'same',()=>expired=true);
24
+ await assert.rejects(client.request('status'));assert.equal(expired,true);
25
+ });
@@ -0,0 +1,10 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import vm from 'node:vm';
5
+ test('route is registered before extension loader returns without awaiting a dynamic import',async()=>{
6
+ const source=await readFile(new URL('./main.js',import.meta.url),'utf8');let registered=false;
7
+ const app={routes:{superuserOnly(path,handler){assert.equal(path,'#/security');assert.equal(typeof handler,'function');registered=true;}},store:{headerLinks:[]},pb:{send(){},buildURL:p=>p}};
8
+ vm.runInNewContext(source,{app,document:{createElement:()=>({}),head:{append(){}}}});
9
+ assert.equal(registered,true);assert.equal(app.store.headerLinks[0].label,'Security');
10
+ });
@@ -0,0 +1,41 @@
1
+ package settings_test
2
+
3
+ import (
4
+ "github.com/pocketbase/pocketbase/core"
5
+ _ "github.com/pocketbase/pocketbase/migrations"
6
+ "github.com/spink-dev/pocketbase-extension/settings"
7
+ "testing"
8
+ )
9
+
10
+ func TestInitialSettingsEnvironment(t *testing.T) {
11
+ t.Setenv("PB_SETTINGS_JSON", `{"meta":{"appName":"Environment","hideControls":false}}`)
12
+ app := core.NewBaseApp(core.BaseAppConfig{DataDir: t.TempDir()})
13
+ settings.Register(app)
14
+ if err := app.Bootstrap(); err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ defer app.ResetBootstrapState()
18
+ if app.Settings().Meta.AppName != "Environment" {
19
+ t.Fatal("ignored environment")
20
+ }
21
+ app.Settings().Meta.AppName = "Dashboard"
22
+ if err := app.Save(app.Settings()); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ t.Setenv("PB_SETTINGS_JSON", `{"meta":{"appName":"Changed environment"}}`)
26
+ if err := app.ReloadSettings(); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ if app.Settings().Meta.AppName != "Dashboard" {
30
+ t.Fatal("overwrote persisted settings")
31
+ }
32
+ }
33
+ func TestInitialSettingsEnvironmentRejectsUnknown(t *testing.T) {
34
+ t.Setenv("PB_SETTINGS_JSON", `{"meta":{"unknownKey":"secret"}}`)
35
+ app := core.NewBaseApp(core.BaseAppConfig{DataDir: t.TempDir()})
36
+ defer app.ResetBootstrapState()
37
+ settings.Register(app)
38
+ if err := app.Bootstrap(); err == nil {
39
+ t.Fatal("accepted unknown settings")
40
+ }
41
+ }
@@ -0,0 +1,193 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Spink PocketBase extension",
5
+ "version": "0.1.0"
6
+ },
7
+ "components": {
8
+ "securitySchemes": {
9
+ "superuser": {
10
+ "type": "apiKey",
11
+ "in": "header",
12
+ "name": "Authorization"
13
+ }
14
+ }
15
+ },
16
+ "paths": {
17
+ "/api/spink/settings": {
18
+ "get": {
19
+ "summary": "Read extension configuration and key availability",
20
+ "responses": {
21
+ "200": {
22
+ "description": "Success"
23
+ },
24
+ "400": {
25
+ "description": "Invalid input"
26
+ },
27
+ "401": {
28
+ "description": "Authentication required"
29
+ },
30
+ "403": {
31
+ "description": "Insufficient permissions"
32
+ },
33
+ "409": {
34
+ "description": "Stale configuration revision"
35
+ }
36
+ },
37
+ "security": [
38
+ {
39
+ "superuser": []
40
+ }
41
+ ]
42
+ },
43
+ "put": {
44
+ "summary": "Save extension configuration with matching revision",
45
+ "responses": {
46
+ "200": {
47
+ "description": "Success"
48
+ },
49
+ "400": {
50
+ "description": "Invalid input"
51
+ },
52
+ "401": {
53
+ "description": "Authentication required"
54
+ },
55
+ "403": {
56
+ "description": "Insufficient permissions"
57
+ },
58
+ "409": {
59
+ "description": "Stale configuration revision"
60
+ }
61
+ },
62
+ "security": [
63
+ {
64
+ "superuser": []
65
+ }
66
+ ],
67
+ "requestBody": {
68
+ "required": true,
69
+ "content": {
70
+ "application/json": {
71
+ "schema": {
72
+ "type": "object",
73
+ "required": [
74
+ "revision",
75
+ "backups"
76
+ ],
77
+ "properties": {
78
+ "revision": {
79
+ "type": "integer"
80
+ },
81
+ "backups": {
82
+ "type": "object",
83
+ "properties": {
84
+ "encrypted": {
85
+ "type": "boolean"
86
+ },
87
+ "encryptionEnv": {
88
+ "type": "string"
89
+ }
90
+ }
91
+ },
92
+ "emailLocales": {
93
+ "type": "object",
94
+ "description": "collection ID/name -> template kind -> lowercase locale -> subject/body"
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ },
103
+ "/api/spink/backups/upload": {
104
+ "post": {
105
+ "summary": "Upload encrypted .zip.age backup",
106
+ "responses": {
107
+ "200": {
108
+ "description": "Success"
109
+ },
110
+ "400": {
111
+ "description": "Invalid input"
112
+ },
113
+ "401": {
114
+ "description": "Authentication required"
115
+ },
116
+ "403": {
117
+ "description": "Insufficient permissions"
118
+ },
119
+ "409": {
120
+ "description": "Stale configuration revision"
121
+ },
122
+ "204": {
123
+ "description": "Uploaded"
124
+ }
125
+ },
126
+ "security": [
127
+ {
128
+ "superuser": []
129
+ }
130
+ ],
131
+ "requestBody": {
132
+ "required": true,
133
+ "content": {
134
+ "multipart/form-data": {
135
+ "schema": {
136
+ "type": "object",
137
+ "required": [
138
+ "file"
139
+ ],
140
+ "properties": {
141
+ "file": {
142
+ "type": "string",
143
+ "format": "binary"
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ },
152
+ "/api/collections/{collection}/records/{id}/duplicate": {
153
+ "post": {
154
+ "summary": "Duplicate a visible base record through stock create authorization",
155
+ "responses": {
156
+ "200": {
157
+ "description": "Success"
158
+ },
159
+ "400": {
160
+ "description": "Invalid input"
161
+ },
162
+ "401": {
163
+ "description": "Authentication required"
164
+ },
165
+ "403": {
166
+ "description": "Insufficient permissions"
167
+ },
168
+ "409": {
169
+ "description": "Stale configuration revision"
170
+ }
171
+ },
172
+ "parameters": [
173
+ {
174
+ "name": "collection",
175
+ "in": "path",
176
+ "required": true,
177
+ "schema": {
178
+ "type": "string"
179
+ }
180
+ },
181
+ {
182
+ "name": "id",
183
+ "in": "path",
184
+ "required": true,
185
+ "schema": {
186
+ "type": "string"
187
+ }
188
+ }
189
+ ]
190
+ }
191
+ }
192
+ }
193
+ }