@x47base/pocketbase-addon 0.1.0 → 0.2.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 (62) hide show
  1. package/Dockerfile +9 -1
  2. package/FEATURES.md +17 -0
  3. package/MIGRATION.md +51 -2
  4. package/NOTICE.md +2 -0
  5. package/README.md +53 -13
  6. package/SECURITY-REVIEW.md +96 -0
  7. package/VERIFY.md +34 -0
  8. package/backups/concurrency_test.go +43 -0
  9. package/backups/register.go +11 -0
  10. package/bin/launcher.test.mjs +31 -0
  11. package/bin/pocketbase-extension.mjs +17 -3
  12. package/cmd/edge/main.go +28 -0
  13. package/cmd/gateway/main.go +84 -0
  14. package/cmd/hosting/main.go +27 -0
  15. package/cmd/pocketbase/main.go +12 -0
  16. package/deploy/README.md +35 -6
  17. package/deploy/compose.yaml +5 -0
  18. package/deploy/edge.json +6 -2
  19. package/edge/gateway.go +110 -35
  20. package/edge/openapi.json +226 -1
  21. package/edge/policy.go +23 -12
  22. package/edge/telemetry.go +187 -0
  23. package/edge/telemetry_test.go +181 -0
  24. package/hosting/README.md +62 -0
  25. package/hosting/backups.go +381 -0
  26. package/hosting/backups_test.go +81 -0
  27. package/hosting/blueprint.example.json +14 -0
  28. package/hosting/blueprint.go +242 -0
  29. package/hosting/blueprint_test.go +98 -0
  30. package/hosting/config.go +126 -0
  31. package/hosting/deploy/dns.example.json +1 -0
  32. package/hosting/docs/DEPLOYMENT.md +98 -0
  33. package/hosting/hosting.example.json +1 -0
  34. package/hosting/local_target_test.go +29 -0
  35. package/hosting/scripts/dns.mjs +116 -0
  36. package/hosting/scripts/routes.mjs +39 -0
  37. package/hosting/ui/main.js +36 -0
  38. package/hosting/ui/page.css +86 -0
  39. package/hosting/ui/page.js +112 -0
  40. package/multinode/README.md +87 -0
  41. package/multinode/gateway.docker.json +1 -0
  42. package/multinode/gateway.example.json +1 -0
  43. package/multinode/gateway.go +347 -0
  44. package/multinode/gateway_test.go +217 -0
  45. package/multinode/security_regression_test.go +106 -0
  46. package/package.json +11 -6
  47. package/scripts/check-edge.py +21 -4
  48. package/scripts/check.sh +5 -2
  49. package/security/README.md +42 -9
  50. package/security/config.go +4 -0
  51. package/security/edge_telemetry.go +123 -0
  52. package/security/edge_telemetry_test.go +87 -0
  53. package/security/management.go +3 -1
  54. package/security/openapi.json +269 -0
  55. package/security/security.go +37 -13
  56. package/security/security_test.go +54 -0
  57. package/security/state.go +12 -7
  58. package/security/ui/dashboard.css +9 -2
  59. package/security/ui/dashboard.js +68 -20
  60. package/security/ui/main.js +16 -4
  61. package/security/ui/model.js +23 -1
  62. package/security/ui/model.test.mjs +12 -0
@@ -18,18 +18,27 @@ import (
18
18
  "github.com/pocketbase/pocketbase/tools/router"
19
19
  )
20
20
 
21
+ // OperatorConcurrency matches the reserved gateway lane and allows parallel admin assets.
22
+ const OperatorConcurrency = 8
23
+
21
24
  type bucket struct {
22
25
  tokens float64
23
26
  updated time.Time
24
27
  }
25
28
  type Observation struct {
26
- Family string `json:"family"`
27
- Count int `json:"count"`
28
- Rejected int `json:"rejected"`
29
- Failures int `json:"failures"`
30
- Slow int `json:"slow"`
29
+ BodyReadBytes uint64 `json:"bodyReadBytes,omitempty"`
30
+ DeclaredRejectedBytes uint64 `json:"declaredRejectedBytes,omitempty"`
31
+ Family string `json:"family"`
32
+ Count int `json:"count"`
33
+ Rejected int `json:"rejected"`
34
+ Failures int `json:"failures"`
35
+ Slow int `json:"slow"`
31
36
  }
32
37
  type Incident struct {
38
+ Source string `json:"source,omitempty"`
39
+ SourceID string `json:"sourceId,omitempty"`
40
+ Reason string `json:"reason,omitempty"`
41
+ Severity string `json:"severity,omitempty"`
33
42
  ID uint64 `json:"id"`
34
43
  Family string `json:"family"`
35
44
  Phase string `json:"phase"`
@@ -49,6 +58,7 @@ type subscriptionState struct {
49
58
  }
50
59
 
51
60
  type TrafficWindow struct {
61
+ Seconds float64 `json:"seconds"`
52
62
  At time.Time `json:"at"`
53
63
  Count int `json:"count"`
54
64
  Rejected int `json:"rejected"`
@@ -57,6 +67,10 @@ type TrafficWindow struct {
57
67
  }
58
68
 
59
69
  type Service struct {
70
+ edge *EdgeTelemetry
71
+ edgeSession string
72
+ edgeSequence uint64
73
+ edgeError bool
60
74
  startedAt time.Time
61
75
  lastEvaluated time.Time
62
76
  history []TrafficWindow
@@ -98,12 +112,13 @@ func New(c Config) (*Service, error) {
98
112
  s.loadState()
99
113
  go func() {
100
114
  defer close(s.done)
101
- t := time.NewTicker(10 * time.Second)
115
+ t := time.NewTicker(time.Second)
102
116
  defer t.Stop()
103
117
  for {
104
118
  select {
105
119
  case now := <-t.C:
106
120
  s.evaluate(now)
121
+ s.readEdge(now)
107
122
  s.persist()
108
123
  case <-s.stop:
109
124
  return
@@ -354,7 +369,7 @@ func (s *Service) Bind(r *router.Router[*core.RequestEvent]) {
354
369
  allowed := true
355
370
  status := 429
356
371
  if management {
357
- allowed = s.active["management"] < 4
372
+ allowed = s.active["management"] < OperatorConcurrency
358
373
  status = 503
359
374
  } else {
360
375
  allowed = consume(&s.global, c.RequestsPerSecond, c.Burst, start)
@@ -440,7 +455,11 @@ func (s *Service) evaluate(now time.Time) {
440
455
  s.observations = map[string]*Observation{}
441
456
  return
442
457
  }
443
- sample := TrafficWindow{At: now}
458
+ seconds := now.Sub(s.lastEvaluated).Seconds()
459
+ if s.lastEvaluated.IsZero() {
460
+ seconds = now.Sub(s.startedAt).Seconds()
461
+ }
462
+ sample := TrafficWindow{At: now, Seconds: max(0.001, seconds)}
444
463
  for _, o := range s.observations {
445
464
  sample.Count += o.Count
446
465
  sample.Rejected += o.Rejected
@@ -467,26 +486,31 @@ func (s *Service) evaluate(now time.Time) {
467
486
  }
468
487
  idx := -1
469
488
  for i := len(s.incidents) - 1; i >= 0; i-- {
470
- if s.incidents[i].Family == f && s.incidents[i].Phase != "resolved" {
489
+ if s.incidents[i].Source != "edge" && s.incidents[i].Family == f && s.incidents[i].Phase != "resolved" {
471
490
  idx = i
472
491
  break
473
492
  }
474
493
  }
475
- if s.streak[f] >= 3 {
494
+ if s.streak[f] >= 1 {
476
495
  if idx < 0 {
477
496
  s.nextID++
478
- s.incidents = append(s.incidents, Incident{ID: s.nextID, Family: f, FirstSeen: now.Add(-30 * time.Second), Phase: "active"})
497
+ s.incidents = append(s.incidents, Incident{ID: s.nextID, Family: f, FirstSeen: now.Add(-time.Duration(sample.Seconds * float64(time.Second))), Source: "pocketbase", Severity: "warning", Phase: "active"})
479
498
  if len(s.incidents) > 256 {
480
499
  s.incidents = s.incidents[1:]
481
500
  }
482
501
  idx = len(s.incidents) - 1
483
502
  }
484
503
  s.incidents[idx].LastSeen = now
485
- s.incidents[idx].Evidence = *o
504
+ e := &s.incidents[idx].Evidence
505
+ e.Family = f
506
+ e.Count += o.Count
507
+ e.Rejected += o.Rejected
508
+ e.Failures += o.Failures
509
+ e.Slow += o.Slow
486
510
  s.incidents[idx].Phase = "active"
487
511
  } else if idx >= 0 && s.clear[f] > 0 {
488
512
  s.incidents[idx].Phase = "recovering"
489
- if s.clear[f] >= 6 {
513
+ if now.Sub(s.incidents[idx].LastSeen) >= time.Minute {
490
514
  s.incidents[idx].Phase = "resolved"
491
515
  }
492
516
  }
@@ -144,3 +144,57 @@ func TestConcurrentObservations(t *testing.T) {
144
144
  t.Fatal(s.total)
145
145
  }
146
146
  }
147
+
148
+ func TestParallelAdminAssetsUseReservedGatewayCapacity(t *testing.T) {
149
+ app, err := tests.NewTestApp()
150
+ if err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ defer app.Cleanup()
154
+ c := DefaultConfig()
155
+ c.Mode = "enforce"
156
+ c.OperatorPeers = []string{"192.0.2.10/32"}
157
+ s, err := New(c)
158
+ if err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ defer s.Close()
162
+ r, _ := apis.NewRouter(app)
163
+ s.Bind(r)
164
+ entered := make(chan struct{}, 8)
165
+ release := make(chan struct{})
166
+ r.GET("/admin-asset", func(e *core.RequestEvent) error { entered <- struct{}{}; <-release; return e.NoContent(204) })
167
+ mux, err := r.BuildMux()
168
+ if err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ request := func() int {
172
+ req := httptest.NewRequest("GET", "/admin-asset", nil)
173
+ req.RemoteAddr = "192.0.2.10:1"
174
+ req.Header.Set("X-Spink-Operator", "1")
175
+ w := httptest.NewRecorder()
176
+ mux.ServeHTTP(w, req)
177
+ return w.Code
178
+ }
179
+ var wg sync.WaitGroup
180
+ defer func() { close(release); wg.Wait() }()
181
+ for i := 0; i < 8; i++ {
182
+ wg.Add(1)
183
+ go func() {
184
+ defer wg.Done()
185
+ if code := request(); code != 204 {
186
+ t.Errorf("parallel asset: %d", code)
187
+ }
188
+ }()
189
+ }
190
+ for i := 0; i < 8; i++ {
191
+ select {
192
+ case <-entered:
193
+ case <-time.After(2 * time.Second):
194
+ t.Fatal("admin assets rejected before the gateway lane was full")
195
+ }
196
+ }
197
+ if code := request(); code != 503 {
198
+ t.Fatalf("unbounded operator lane: %d", code)
199
+ }
200
+ }
package/security/state.go CHANGED
@@ -11,12 +11,14 @@ import (
11
11
  )
12
12
 
13
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"`
14
+ EdgeSession string `json:"edgeSession,omitempty"`
15
+ EdgeSequence uint64 `json:"edgeSequence,omitempty"`
16
+ Version int `json:"version"`
17
+ Revision uint64 `json:"revision"`
18
+ Policy Config `json:"policy"`
19
+ Incidents []Incident `json:"incidents"`
20
+ Actions map[string]Action `json:"actions"`
21
+ NextID uint64 `json:"nextId"`
20
22
  }
21
23
 
22
24
  func (s *Service) loadState() {
@@ -59,8 +61,11 @@ func (s *Service) loadState() {
59
61
  }
60
62
  state.Policy.OperatorPeers = s.config.OperatorPeers
61
63
  state.Policy.StatePath = s.config.StatePath
64
+ state.Policy.EdgeStatePath = s.config.EdgeStatePath
62
65
  state.Policy.Mode = s.config.Mode
63
66
  s.config = state.Policy
67
+ s.edgeSession = state.EdgeSession
68
+ s.edgeSequence = state.EdgeSequence
64
69
  s.revision = state.Revision
65
70
  s.incidents = state.Incidents
66
71
  s.actions = state.Actions
@@ -74,7 +79,7 @@ func (s *Service) persist() {
74
79
  defer s.journalMu.Unlock()
75
80
  s.mu.Lock()
76
81
  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}
82
+ state := checkpoint{EdgeSession: s.edgeSession, EdgeSequence: s.edgeSequence, Version: 1, Revision: s.revision, Policy: cloneConfig(s.config), Incidents: append([]Incident{}, s.incidents...), Actions: maps.Clone(s.actions), NextID: s.nextID}
78
83
  s.mu.Unlock()
79
84
  if path == "" {
80
85
  return
@@ -1,4 +1,11 @@
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)}
1
+ .pb-security{--sec-bg:var(--surfaceColor);--sec-text:var(--surfaceTxtColor);--sec-muted:var(--surfaceTxtHintColor);--sec-line:var(--surfaceAlt3Color);--sec-soft:var(--surfaceAlt1Color);color:var(--sec-text)}
2
+ .pb-security>.page-content{padding:28px 32px;max-width:1500px;width:100%;min-width:0;margin:0 auto}
2
3
  .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
+ @media(max-width:760px){.pb-security>.page-content{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
5
  .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}
6
+
7
+ .pb-security .sec-edge{border-top:3px solid var(--infoColor)}
8
+ .pb-security .sec-reason{display:flex;justify-content:space-between;gap:18px;border-bottom:1px solid var(--sec-line);padding:10px 0;font-size:14px}
9
+ .pb-security .sec-reason strong{font-variant-numeric:tabular-nums}
10
+ .pb-security .sec-warning{border-left:3px solid #c79030;padding:12px 16px;background:var(--sec-soft)}
11
+ .pb-security .sec-stat strong{overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
@@ -1,4 +1,4 @@
1
- import { families, familyLabels, filterIncidents, percent, rate, timestamp, sessionRequest } from './model.js';
1
+ import { families, familyLabels, filterIncidents, percent, rate, timestamp, sessionRequest, reasonLabels, bytes, edgeState } from './model.js';
2
2
 
3
3
  const fields = [
4
4
  ['requestsPerSecond','Global requests / second','Total admission rate before authentication.'],
@@ -19,13 +19,9 @@ function badge(label, state) { return el('span',label,'sec-badge sec-'+state); }
19
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
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
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) {
22
+ export function mount(root, app) {
27
23
  app.store.title='Security';
28
- let view='overview', phase='', family='', status=null, incidents=[], draft=null, revision='', dirty=false, timer, busy=false, mutation=false;
24
+ let view='overview', phase='', family='', source='', status=null, incidents=[], draft=null, revision='', dirty=false, timer, busy=false, mutation=false;
29
25
  let closed=false, sessionGone=false, lastRefresh=null, operatorNotice=false;
30
26
  const session=()=>app.pb.authStore.token;
31
27
  const client=sessionRequest((url,options)=>app.pb.send(url,options),session,()=>app.pb.authStore.clear());
@@ -37,24 +33,76 @@ function mount(root, app) {
37
33
  const unsubscribe=app.pb.authStore.onChange(()=>{if(!checkSession()) window.location.hash='#/login';});
38
34
  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
35
  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);}}
36
+ function renderTabs(){
37
+ if (!tabs.childElementCount) for (const [id,label] of [['overview','Overview'],['incidents','Incidents'],['protection','Protection']]) {
38
+ const tab=button(label,()=>{view=id;render();},'sec-tab');tab.dataset.view=id;tabs.append(tab);
39
+ }
40
+ for (const tab of tabs.children) tab.setAttribute('aria-current',view===tab.dataset.view?'page':'false');
41
+ }
41
42
  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
43
  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
44
  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);
45
+ edgeOverview();
46
+ content.append(el('h2','PocketBase application traffic'));
47
+ const active=incidents.filter(i=>i.source!=='edge'&&i.phase!=='resolved').length, last=status.traffic?.at(-1);
48
+ stats(content,[['Application protection',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','Latest completed evaluation window.'],['Application flags',status.rejected,status.mode==='observe'?'Would be limited; requests still proceed.':'Admission decisions since this process started.']]);
49
+ const grid=el('div',null,'sec-columns');const traffic=el('section',null,'sec-panel');traffic.append(el('h2','Traffic over time'),el('p','Last 60 evaluations · normally one per second.','sec-muted'));
50
+ 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 evaluation 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.`));}
51
+ else traffic.append(el('p',status.mode==='disabled'?'Enable observation to collect traffic windows.':'Waiting for the first observation window.','sec-empty'));
52
+ 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','One-second application evaluation; edge denials recorded immediately'],['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
53
  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
54
  content.append(button('Review incidents',()=>{view='incidents';render();}));
52
55
  }
56
+ function edgeOverview() {
57
+ const state = edgeState(status), edge = status.edge;
58
+ const panel = el('section', null, 'sec-panel sec-edge');
59
+ const heading = el('div', null, 'sec-incident-heading');
60
+ heading.append(el('h2', 'Public firewall'), badge(state === 'live' ? 'Telemetry live' : state === 'unconfigured' ? 'Not connected' : 'Telemetry unavailable', state === 'live' ? 'resolved' : 'active'));
61
+ panel.append(heading);
62
+ if (state !== 'live') panel.append(el('p', state === 'unconfigured' ? 'Firewall traffic is not connected. Requests blocked before PocketBase are invisible here. Configure PB_SECURITY_EDGE_STATE_PATH and the separate telemetry volume.' : 'The firewall snapshot is missing, invalid or older than five seconds. Values below are the last known state, not current protection status.', 'sec-warning'));
63
+ if (edge) {
64
+ const open=(edge.incidents||[]).filter(i=>i.phase!=='resolved');
65
+ if (open.length) panel.append(el('p',open.length+' firewall event groups active or recovering in this snapshot · '+open.filter(i=>i.severity==='warning').length+' elevated signals. Open Incidents to review the evidence.','sec-warning'));
66
+ stats(panel, [
67
+ ['Public requests', Number(edge.requests).toLocaleString(), 'Since firewall process start'],
68
+ ['Blocked at firewall', Number(edge.rejected).toLocaleString(), percent(edge.rejected,edge.requests)+'% · never forwarded'],
69
+ ['Forwarded', Number(edge.forwarded).toLocaleString(), 'Admitted to the upstream proxy'],
70
+ ['Body bytes read', bytes(edge.bodyReadBytes), 'HTTP payload read by the firewall'],
71
+ ]);
72
+ const grid=el('div',null,'sec-columns'), reasons=el('section');
73
+ reasons.append(el('h3','Why requests were blocked'));
74
+ const list=el('div',null,'sec-reasons');
75
+ const rows=Object.entries(edge.reasons||{}).sort((a,b)=>b[1]-a[1]);
76
+ for (const [key,count] of rows) { const row=el('div',null,'sec-reason');row.append(el('span',reasonLabels[key]||key),el('strong',Number(count).toLocaleString()));list.append(row); }
77
+ if (!rows.length) list.append(el('p','No public requests rejected in this firewall session.','sec-muted'));
78
+ reasons.append(list);
79
+ const limits=el('section');limits.append(el('h3','Capacity and upload limits'));
80
+ const facts=el('dl',null,'sec-facts');
81
+ for (const [label,value] of [
82
+ ['Policy',edge.policyReady?'Ready':'Unavailable — fails closed'],['Upstream health',edge.upstreamHealthy?'Responding':'Unavailable'],
83
+ ['Active requests',edge.active+' / '+edge.maxConcurrent],['Concurrent / client',edge.maxClientConcurrent],
84
+ ['Maximum upload',bytes(edge.maxBodyBytes)],['Upload deadline',edge.bodyReadTimeoutSeconds+' seconds'],
85
+ ['Upstream errors',edge.upstreamFailures],['Claimed bytes rejected',bytes(edge.declaredRejectedBytes)],
86
+ ['Firewall started',timestamp(edge.startedAt)],['Snapshot',timestamp(edge.updatedAt)],
87
+ ]) facts.append(el('dt',label),el('dd',String(value)));
88
+ limits.append(facts);grid.append(reasons,limits);panel.append(grid);
89
+ if (edge.traffic?.length) {
90
+ panel.append(el('h3','Public traffic · last minute'));
91
+ const chart=el('div',null,'sec-chart');chart.setAttribute('role','img');chart.setAttribute('aria-label','Public requests and denials in one-second buckets');
92
+ const windows=Array.from({length:60},(_,i)=>{const at=Math.floor(new Date(edge.updatedAt).valueOf()/1000)-(59-i);return edge.traffic.find(w=>Math.floor(new Date(w.at).valueOf()/1000)===at)||{count:0,rejected:0};});
93
+ const peak=Math.max(1,...windows.map(w=>Math.max(w.count,w.rejected)));
94
+ for (const w of windows) { const bar=el('div',null,'sec-bar');bar.style.height=Math.max(1,100*Math.max(w.count,w.rejected)/peak)+'%';bar.title=(w.at?timestamp(w.at)+': ':'')+w.count+' arrivals, '+w.rejected+' rejections';const blocked=el('span');blocked.style.height=percent(w.rejected,Math.max(w.count,w.rejected))+'%';bar.append(blocked);chart.append(bar); }
95
+ panel.append(chart,el('p','Blue: arrivals · amber: rejection decisions. Uploads can finish in a later bucket.','sec-muted'));
96
+ }
97
+ }
98
+ panel.append(el('p','Firewall and application counts cover different stages and must not be added together. Claimed size comes from Content-Length; it is not transferred bandwidth. Connections rejected before HTTP parsing and provider-level traffic are not measured here.','sec-context'));
99
+ content.append(panel);
100
+ }
53
101
  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);
102
+ 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 (PocketBase)',familyInput));const sourceInput=select([['','All sources'],['edge','Public firewall'],['pocketbase','PocketBase']],source);sourceInput.onchange=()=>{source=sourceInput.value;if(source==='edge')family='';render();};familyInput.disabled=source==='edge';filters.append(labelField('Source',sourceInput));content.append(filters);
103
+ const rows=filterIncidents(incidents,phase,family).filter(i=>!source||(i.source||'pocketbase')===source);
104
+ 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 one suspicious application window. Firewall denials are recorded individually and grouped by reason.'));content.append(empty);return;}
105
+ 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',(item.source==='edge'?(reasonLabels[item.reason]||item.reason):(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'));card.append(el('p',(item.source==='edge'?'Public firewall':'PocketBase')+' · '+(item.severity==='warning'?'Elevated signal — review recommended':'Policy event — not proof of an attack'),'sec-muted'));const o=item.evidence||{};stats(card,item.source==='edge'?[['Blocked requests',o.rejected||0,'Accumulated for this event'],['Body bytes read',bytes(o.bodyReadBytes),'Payload read before rejection'],['Claimed size',bytes(o.declaredRejectedBytes),'Content-Length; not received bandwidth']]:[['Requests',o.count||0,'Accumulated evidence'],['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
106
  }
59
107
  function renderActions(list=content.querySelector('[data-sec-actions]')) {
60
108
  if(!list||!status)return;list.replaceChildren();
@@ -76,8 +124,8 @@ function mount(root, app) {
76
124
  if(!draft){const snapshot=await getPolicy();draft=snapshot.policy;revision=snapshot.revision;}
77
125
  if(!checkSession())return;status=nextStatus;incidents=all;lastRefresh=new Date();updated.textContent='Updated '+lastRefresh.toLocaleTimeString();
78
126
  }
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);}}
127
+ async function refresh(manual=false){if(document.hidden&&!manual){timer=setTimeout(()=>refresh(),2000);return;}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(),2000);}}
128
+ 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(),2000);}}
81
129
  refreshButton.onclick=()=>{clearTimeout(timer);refresh(true);};renderTabs();refresh();
82
130
  return ()=>{closed=true;clearTimeout(timer);unsubscribe();client.close();root.replaceChildren();};
83
131
  }
@@ -4,11 +4,23 @@ if (!app.routes?.superuserOnly || !app.store?.headerLinks || !app.pb?.send) {
4
4
  const base = app.pb.buildURL('/_/extensions/security/');
5
5
  const stylesheet = document.createElement('link');
6
6
  stylesheet.rel = 'stylesheet';
7
- stylesheet.href = base + 'dashboard.css';
7
+ stylesheet.href = base + 'dashboard.css?v=2';
8
8
  document.head.append(stylesheet);
9
- app.routes.superuserOnly('#/security', async () => {
10
- const { securityPage } = await import(base + 'dashboard.js');
11
- return securityPage(app);
9
+ app.routes.superuserOnly('#/security', () => {
10
+ let cancelled = false, dispose = () => {};
11
+ return t.div({className: 'page pb-security'}, t.div({
12
+ className: 'page-content',
13
+ onmount: async root => {
14
+ root.textContent = 'Loading security…';
15
+ try {
16
+ const { mount } = await import(base + 'dashboard.js?v=2');
17
+ if (!cancelled && root.isConnected) dispose = mount(root, app);
18
+ } catch {
19
+ if (!cancelled) root.textContent = 'Security could not be loaded. Refresh the page to try again.';
20
+ }
21
+ },
22
+ onunmount: () => { cancelled = true; dispose(); },
23
+ }));
12
24
  });
13
25
  if (!app.store.headerLinks.some(link => link.href === '#/security')) {
14
26
  app.store.headerLinks.push({ href: '#/security', label: 'Security', icon: 'ri-shield-check-line' });
@@ -1,6 +1,6 @@
1
1
  export const families = ['auth', 'reads', 'writes', 'files', 'realtime', 'other'];
2
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; }
3
+ export function rate(window) { return Number(window?.count || 0) / Math.max(0.001, Number(window?.seconds || 10)); }
4
4
  export function percent(part, total) { return total > 0 ? Math.round(100 * part / total) : 0; }
5
5
  export function filterIncidents(items, phase, family) {
6
6
  return items.filter(i => (!phase || (phase === 'unreviewed' ? !i.acknowledged : i.phase === phase)) && (!family || i.family === family)).sort((a,b) => b.id-a.id);
@@ -30,3 +30,25 @@ export function sessionRequest(send, getSession, onExpired = () => {}) {
30
30
  valid() { return alive && getSession() === initial; },
31
31
  };
32
32
  }
33
+
34
+ export const reasonLabels = {
35
+ body_too_large: 'Upload exceeds size limit', body_timeout: 'Upload exceeded time limit',
36
+ body_read_error: 'Interrupted or invalid upload', private_route: 'Private or unlisted route',
37
+ blocked_path: 'Blocked path', blocked_network: 'Blocked network', invalid_path: 'Ambiguous URL',
38
+ invalid_client: 'Invalid client address', policy_unavailable: 'Policy missing or stale',
39
+ global_rate: 'Global request rate', client_rate: 'Client request rate',
40
+ client_capacity: 'Client tracking capacity', global_concurrency: 'Concurrent request capacity',
41
+ client_concurrency: 'Concurrent requests from one client', family_block: 'Temporary family block',
42
+ };
43
+ export function bytes(value) {
44
+ const n = Math.max(0, Number(value || 0));
45
+ if (n < 1024) return n.toLocaleString() + ' B';
46
+ const unit = Math.min(4, Math.floor(Math.log2(n) / 10));
47
+ return (n / 1024 ** unit).toLocaleString(undefined, {maximumFractionDigits: 1}) + ' ' + ['B','KiB','MiB','GiB','TiB'][unit];
48
+ }
49
+ export function edgeState(status, now = Date.now()) {
50
+ if (!status?.edgeConfigured) return 'unconfigured';
51
+ if (!status.edge) return 'unavailable';
52
+ if (status.edgeStale || now - new Date(status.edge.updatedAt).valueOf() > 5000) return 'stale';
53
+ return 'live';
54
+ }
@@ -23,3 +23,15 @@ test('authentication failure invokes login handling',async()=>{
23
23
  const client=sessionRequest(async()=>{throw Object.assign(new Error('expired'),{status:401});},()=> 'same',()=>expired=true);
24
24
  await assert.rejects(client.request('status'));assert.equal(expired,true);
25
25
  });
26
+
27
+ test('one-second rates and absent or stale edge snapshots are explicit', async()=>{
28
+ const {edgeState,bytes}=await import('./model.js');
29
+ assert.equal(rate({count:120,seconds:1}),120);
30
+ assert.equal(rate({count:120,seconds:2}),60);
31
+ assert.equal(edgeState({edgeConfigured:false}),'unconfigured');
32
+ assert.equal(edgeState({edgeConfigured:true}),'unavailable');
33
+ const now=Date.now();const status={edgeConfigured:true,edge:{updatedAt:new Date(now).toISOString()}};
34
+ assert.equal(edgeState(status,now),'live');
35
+ assert.equal(edgeState(status,now+6000),'stale');
36
+ assert.equal(bytes(1<<30),'1 GiB');
37
+ });