@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.
- package/Dockerfile +9 -1
- package/FEATURES.md +17 -0
- package/MIGRATION.md +51 -2
- package/NOTICE.md +2 -0
- package/README.md +53 -13
- package/SECURITY-REVIEW.md +96 -0
- package/VERIFY.md +34 -0
- package/backups/concurrency_test.go +43 -0
- package/backups/register.go +11 -0
- package/bin/launcher.test.mjs +31 -0
- package/bin/pocketbase-extension.mjs +17 -3
- package/cmd/edge/main.go +28 -0
- package/cmd/gateway/main.go +84 -0
- package/cmd/hosting/main.go +27 -0
- package/cmd/pocketbase/main.go +12 -0
- package/deploy/README.md +35 -6
- package/deploy/compose.yaml +5 -0
- package/deploy/edge.json +6 -2
- package/edge/gateway.go +110 -35
- package/edge/openapi.json +226 -1
- package/edge/policy.go +23 -12
- package/edge/telemetry.go +187 -0
- package/edge/telemetry_test.go +181 -0
- package/hosting/README.md +62 -0
- package/hosting/backups.go +381 -0
- package/hosting/backups_test.go +81 -0
- package/hosting/blueprint.example.json +14 -0
- package/hosting/blueprint.go +242 -0
- package/hosting/blueprint_test.go +98 -0
- package/hosting/config.go +126 -0
- package/hosting/deploy/dns.example.json +1 -0
- package/hosting/docs/DEPLOYMENT.md +98 -0
- package/hosting/hosting.example.json +1 -0
- package/hosting/local_target_test.go +29 -0
- package/hosting/scripts/dns.mjs +116 -0
- package/hosting/scripts/routes.mjs +39 -0
- package/hosting/ui/main.js +36 -0
- package/hosting/ui/page.css +86 -0
- package/hosting/ui/page.js +112 -0
- package/multinode/README.md +87 -0
- package/multinode/gateway.docker.json +1 -0
- package/multinode/gateway.example.json +1 -0
- package/multinode/gateway.go +347 -0
- package/multinode/gateway_test.go +217 -0
- package/multinode/security_regression_test.go +106 -0
- package/package.json +11 -6
- package/scripts/check-edge.py +21 -4
- package/scripts/check.sh +5 -2
- package/security/README.md +42 -9
- package/security/config.go +4 -0
- package/security/edge_telemetry.go +123 -0
- package/security/edge_telemetry_test.go +87 -0
- package/security/management.go +3 -1
- package/security/openapi.json +269 -0
- package/security/security.go +37 -13
- package/security/security_test.go +54 -0
- package/security/state.go +12 -7
- package/security/ui/dashboard.css +9 -2
- package/security/ui/dashboard.js +68 -20
- package/security/ui/main.js +16 -4
- package/security/ui/model.js +23 -1
- package/security/ui/model.test.mjs +12 -0
package/security/security.go
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
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"] <
|
|
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
|
-
|
|
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] >=
|
|
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(-
|
|
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
|
|
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.
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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{
|
|
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}
|
package/security/ui/dashboard.js
CHANGED
|
@@ -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
|
|
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(){
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
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
|
|
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,'
|
|
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(),
|
|
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(),
|
|
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
|
}
|
package/security/ui/main.js
CHANGED
|
@@ -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',
|
|
10
|
-
|
|
11
|
-
return
|
|
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' });
|
package/security/ui/model.js
CHANGED
|
@@ -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
|
+
});
|