@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,49 @@
1
+ package mail
2
+
3
+ import (
4
+ "github.com/pocketbase/pocketbase/core"
5
+ "github.com/pocketbase/pocketbase/mails"
6
+ "github.com/pocketbase/pocketbase/tests"
7
+ "github.com/spink-dev/pocketbase-extension/settings"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestLocaleFallbackAndRequestIsolation(t *testing.T) {
13
+ a, err := tests.NewTestApp()
14
+ if err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ defer a.Cleanup()
18
+ Register(a)
19
+ r, err := a.FindFirstRecordByData("users", "email", "test@example.com")
20
+ if err != nil {
21
+ rs, e := a.FindRecordsByFilter("users", "", "", 1, 0)
22
+ if e != nil || len(rs) == 0 {
23
+ t.Fatal(err)
24
+ }
25
+ r = rs[0]
26
+ }
27
+ c, _ := settings.Load(a)
28
+ c.EmailLocales = map[string]map[string]map[string]settings.Template{r.Collection().Id: {"otp": {"fr": {Subject: "Bonjour {RECORD:email}", Body: "Code {OTP}"}}}}
29
+ if err = settings.Save(a, c); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ subjects := []string{}
33
+ a.OnMailerRecordOTPSend().BindFunc(func(e *core.MailerRecordEvent) error {
34
+ subjects = append(subjects, e.Message.Subject)
35
+ if len(subjects) == 1 && !strings.Contains(e.Message.HTML, "Code 123456") {
36
+ t.Fatal("OTP placeholder missing")
37
+ }
38
+ return nil
39
+ })
40
+ if err = mails.SendRecordOTP(WithLocale(a, "fr-ch"), r, "fixture", "123456"); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if err = mails.SendRecordOTP(a, r, "fixture", "123456"); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if len(subjects) != 2 || !strings.HasPrefix(subjects[0], "Bonjour") || strings.HasPrefix(subjects[1], "Bonjour") {
47
+ t.Fatalf("locale leaked: %v", subjects)
48
+ }
49
+ }
@@ -0,0 +1,91 @@
1
+ package mail
2
+
3
+ import (
4
+ "bytes"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "github.com/pocketbase/pocketbase/mails/templates"
7
+ "html"
8
+ "html/template"
9
+ "slices"
10
+ texttemplate "text/template"
11
+ )
12
+
13
+ var nonescapeTypes = []string{core.FieldTypeNumber, core.FieldTypeBool, core.FieldTypeDate, core.FieldTypeAutodate}
14
+
15
+ func resolveTemplateContent(data any, content ...string) (string, error) {
16
+ if len(content) == 0 {
17
+ return "", nil
18
+ }
19
+
20
+ t := texttemplate.New("inline_template")
21
+
22
+ var parseErr error
23
+ for _, v := range content {
24
+ t, parseErr = t.Parse(v)
25
+ if parseErr != nil {
26
+ return "", parseErr
27
+ }
28
+ }
29
+
30
+ var wr bytes.Buffer
31
+
32
+ if executeErr := t.Execute(&wr, data); executeErr != nil {
33
+ return "", executeErr
34
+ }
35
+
36
+ return wr.String(), nil
37
+ }
38
+
39
+ func resolveEmailTemplate(
40
+ app core.App,
41
+ authRecord *core.Record,
42
+ emailTemplate core.EmailTemplate,
43
+ placeholders map[string]any,
44
+ ) (subject string, body string, err error) {
45
+ if placeholders == nil {
46
+ placeholders = map[string]any{}
47
+ }
48
+
49
+ // register default system placeholders
50
+ if _, ok := placeholders[core.EmailPlaceholderAppName]; !ok {
51
+ placeholders[core.EmailPlaceholderAppName] = app.Settings().Meta.AppName
52
+ }
53
+ if _, ok := placeholders[core.EmailPlaceholderAppURL]; !ok {
54
+ placeholders[core.EmailPlaceholderAppURL] = app.Settings().Meta.AppURL
55
+ }
56
+
57
+ // register default auth record placeholders
58
+ for _, field := range authRecord.Collection().Fields {
59
+ if field.GetHidden() {
60
+ continue
61
+ }
62
+
63
+ fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
64
+ if _, ok := placeholders[fieldPlacehodler]; !ok {
65
+ val := authRecord.GetString(field.GetName())
66
+
67
+ // note: the escaping is not strictly necessary but for just in case
68
+ // the user decide to store and render the email as plain html
69
+ if !slices.Contains(nonescapeTypes, field.Type()) {
70
+ val = html.EscapeString(val)
71
+ }
72
+
73
+ placeholders[fieldPlacehodler] = val
74
+ }
75
+ }
76
+
77
+ subject, rawBody := emailTemplate.Resolve(placeholders)
78
+
79
+ params := struct {
80
+ HTMLContent template.HTML
81
+ }{
82
+ HTMLContent: template.HTML(rawBody),
83
+ }
84
+
85
+ body, err = resolveTemplateContent(params, templates.Layout, templates.HTMLBody)
86
+ if err != nil {
87
+ return "", "", err
88
+ }
89
+
90
+ return subject, body, nil
91
+ }
@@ -0,0 +1,96 @@
1
+ // Package migration explicitly imports fork-only settings into extension-owned storage.
2
+ package migration
3
+
4
+ import (
5
+ "encoding/json"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tools/security"
8
+ "github.com/spink-dev/pocketbase-extension/settings"
9
+ "os"
10
+ "reflect"
11
+ )
12
+
13
+ func Import(app core.App) (bool, error) {
14
+ c, err := settings.Load(app)
15
+ if err != nil {
16
+ return false, err
17
+ }
18
+ before, _ := json.Marshal(c)
19
+ p := new(core.Param)
20
+ if err = app.ModelQuery(p).Model("settings", p); err != nil {
21
+ return false, err
22
+ }
23
+ raw := []byte(p.Value)
24
+ var old struct {
25
+ Backups struct {
26
+ Encrypted bool
27
+ EncryptionEnv string
28
+ }
29
+ }
30
+ if err = json.Unmarshal(raw, &old); err != nil {
31
+ raw, err = security.Decrypt(string(p.Value), os.Getenv(app.EncryptionEnv()))
32
+ if err != nil {
33
+ return false, err
34
+ }
35
+ if err = json.Unmarshal(raw, &old); err != nil {
36
+ return false, err
37
+ }
38
+ }
39
+ if c.Revision == 1 {
40
+ c.Backups.Encrypted = old.Backups.Encrypted
41
+ c.Backups.EncryptionEnv = old.Backups.EncryptionEnv
42
+ }
43
+ var rows []struct {
44
+ Id string `db:"id"`
45
+ Options string `db:"options"`
46
+ }
47
+ if err = app.DB().NewQuery("SELECT id, options FROM _collections WHERE type='auth'").All(&rows); err != nil {
48
+ return false, err
49
+ }
50
+ for _, row := range rows {
51
+ var opts map[string]json.RawMessage
52
+ if err = json.Unmarshal([]byte(row.Options), &opts); err != nil {
53
+ return false, err
54
+ }
55
+ for key, kind := range map[string]string{"otp": "otp", "authAlert": "authAlert", "verificationTemplate": "verification", "resetPasswordTemplate": "passwordReset", "confirmEmailChangeTemplate": "emailChange"} {
56
+ data := opts[key]
57
+ if len(data) == 0 {
58
+ continue
59
+ }
60
+ if key == "otp" || key == "authAlert" {
61
+ var nested map[string]json.RawMessage
62
+ if err = json.Unmarshal(data, &nested); err != nil {
63
+ return false, err
64
+ }
65
+ data = nested["emailTemplate"]
66
+ }
67
+ if len(data) == 0 {
68
+ continue
69
+ }
70
+ var t struct{ Locales map[string]settings.Template }
71
+ if err = json.Unmarshal(data, &t); err != nil {
72
+ return false, err
73
+ }
74
+ if len(t.Locales) == 0 {
75
+ continue
76
+ }
77
+ if c.EmailLocales == nil {
78
+ c.EmailLocales = map[string]map[string]map[string]settings.Template{}
79
+ }
80
+ if c.EmailLocales[row.Id] == nil {
81
+ c.EmailLocales[row.Id] = map[string]map[string]settings.Template{}
82
+ }
83
+ if c.EmailLocales[row.Id][kind] == nil {
84
+ c.EmailLocales[row.Id][kind] = t.Locales
85
+ }
86
+ }
87
+ }
88
+ after, _ := json.Marshal(c)
89
+ if reflect.DeepEqual(before, after) {
90
+ return false, nil
91
+ }
92
+ if err = settings.Save(app, c); err != nil {
93
+ return false, err
94
+ }
95
+ return true, nil
96
+ }
@@ -0,0 +1,58 @@
1
+ package migration
2
+
3
+ import (
4
+ "encoding/json"
5
+ "github.com/pocketbase/dbx"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "github.com/spink-dev/pocketbase-extension/settings"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ func TestImportForkSettingsIsExplicitAndIdempotent(t *testing.T) {
14
+ a, err := tests.NewTestApp()
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ defer a.Cleanup()
19
+ t.Setenv("SPINK_IMPORT_KEY", strings.Repeat("k", 32))
20
+ p := new(core.Param)
21
+ if err = a.ModelQuery(p).Model("settings", p); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ var data map[string]any
25
+ if err = json.Unmarshal(p.Value, &data); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ data["backups"].(map[string]any)["encrypted"] = true
29
+ data["backups"].(map[string]any)["encryptionEnv"] = "SPINK_IMPORT_KEY"
30
+ raw, _ := json.Marshal(data)
31
+ if _, err = a.DB().NewQuery("UPDATE _params SET value={:v} WHERE id='settings'").Bind(dbx.Params{"v": string(raw)}).Execute(); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ c, _ := a.FindCollectionByNameOrId("users")
35
+ var options string
36
+ if err = a.DB().NewQuery("SELECT options FROM _collections WHERE id={:id}").Bind(dbx.Params{"id": c.Id}).Row(&options); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ var opts map[string]any
40
+ json.Unmarshal([]byte(options), &opts)
41
+ opts["verificationTemplate"].(map[string]any)["locales"] = map[string]any{"fr": map[string]any{"subject": "Bonjour", "body": "Bienvenue"}}
42
+ raw, _ = json.Marshal(opts)
43
+ if _, err = a.DB().NewQuery("UPDATE _collections SET options={:v} WHERE id={:id}").Bind(dbx.Params{"v": string(raw), "id": c.Id}).Execute(); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ changed, err := Import(a)
47
+ if err != nil || !changed {
48
+ t.Fatalf("import: %v %v", changed, err)
49
+ }
50
+ got, err := settings.Load(a)
51
+ if err != nil || !got.Backups.Encrypted || got.EmailLocales[c.Id]["verification"]["fr"].Subject != "Bonjour" {
52
+ t.Fatal("lost imported configuration")
53
+ }
54
+ changed, err = Import(a)
55
+ if err != nil || changed {
56
+ t.Fatal("import is not idempotent")
57
+ }
58
+ }
package/otp/otp.go ADDED
@@ -0,0 +1,56 @@
1
+ // Package otp exposes a pre-validation password callback while leaving stock OTP
2
+ // expiry, single-use, MFA and token issuance authoritative.
3
+ package otp
4
+
5
+ import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "github.com/pocketbase/pocketbase/core"
9
+ "github.com/pocketbase/pocketbase/tools/router"
10
+ "io"
11
+ "strings"
12
+ )
13
+
14
+ type Request struct {
15
+ Event *core.RequestEvent
16
+ OTPID string
17
+ Password string
18
+ }
19
+
20
+ func Register(app core.App, callback func(*Request) error) {
21
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
22
+ e.Router.BindFunc(func(r *core.RequestEvent) error {
23
+ if r.Request.Method != "POST" || !strings.HasSuffix(r.Request.URL.Path, "/auth-with-otp") {
24
+ return r.Next()
25
+ }
26
+ data, err := io.ReadAll(io.LimitReader(r.Request.Body, (1<<20)+1))
27
+ if err != nil {
28
+ return err
29
+ }
30
+ if len(data) > 1<<20 {
31
+ return r.BadRequestError("OTP request too large", nil)
32
+ }
33
+ var body map[string]any
34
+ if err = json.Unmarshal(data, &body); err != nil {
35
+ return r.BadRequestError("OTP extension requires JSON", err)
36
+ }
37
+ req := &Request{Event: r}
38
+ req.OTPID, _ = body["otpId"].(string)
39
+ req.Password, _ = body["password"].(string)
40
+ if err = callback(req); err != nil {
41
+ return err
42
+ }
43
+ body["password"] = req.Password
44
+ data, err = json.Marshal(body)
45
+ if err != nil {
46
+ return err
47
+ }
48
+ bodyReader := &router.RereadableReadCloser{ReadCloser: io.NopCloser(bytes.NewReader(data))}
49
+ defer bodyReader.Close()
50
+ r.Request.Body = bodyReader
51
+ r.Request.ContentLength = int64(len(data))
52
+ return r.Next()
53
+ })
54
+ return e.Next()
55
+ })
56
+ }
@@ -0,0 +1,56 @@
1
+ package otp
2
+
3
+ import (
4
+ "fmt"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ func TestPasswordCallbackKeepsNativeSingleUse(t *testing.T) {
14
+ a, err := tests.NewTestApp()
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ defer a.Cleanup()
19
+ c, _ := a.FindCollectionByNameOrId("users")
20
+ c.OTP.Enabled = true
21
+ c.MFA.Enabled = false
22
+ if err = a.Save(c); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ rs, err := a.FindRecordsByFilter(c.Id, "", "", 1, 0)
26
+ if err != nil || len(rs) == 0 {
27
+ t.Fatal(err)
28
+ }
29
+ o := core.NewOTP(a)
30
+ o.SetCollectionRef(c.Id)
31
+ o.SetRecordRef(rs[0].Id)
32
+ o.SetPassword("123456")
33
+ if err = a.Save(o); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ Register(a, func(r *Request) error {
37
+ if r.Password == "mapped" {
38
+ r.Password = "123456"
39
+ }
40
+ return nil
41
+ })
42
+ router, _ := apis.NewRouter(a)
43
+ if err = a.OnServe().Trigger(&core.ServeEvent{App: a, Router: router}); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ h, _ := router.BuildMux()
47
+ for i, want := range []int{200, 400} {
48
+ r := httptest.NewRequest("POST", "/api/collections/"+c.Id+"/auth-with-otp", strings.NewReader(fmt.Sprintf(`{"otpId":%q,"password":"mapped"}`, o.Id)))
49
+ r.Header.Set("Content-Type", "application/json")
50
+ w := httptest.NewRecorder()
51
+ h.ServeHTTP(w, r)
52
+ if w.Code != want {
53
+ t.Fatalf("attempt %d got %d: %s", i, w.Code, w.Body.String())
54
+ }
55
+ }
56
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@x47base/pocketbase-addon",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test security/ui/*.test.mjs",
7
+ "check": "./scripts/check.sh"
8
+ },
9
+ "files": [
10
+ "*.go",
11
+ "go.mod",
12
+ "go.sum",
13
+ "admin",
14
+ "backups",
15
+ "cmd",
16
+ "features",
17
+ "internal",
18
+ "jsvm",
19
+ "loadtest",
20
+ "localization",
21
+ "mail",
22
+ "migration",
23
+ "otp",
24
+ "security",
25
+ "settings",
26
+ "watcher",
27
+ "web",
28
+ "bin",
29
+ "scripts",
30
+ "README.md",
31
+ "MIGRATION.md",
32
+ "FEATURES.md",
33
+ "LICENSE.md",
34
+ "NOTICE.md",
35
+ "edge",
36
+ "Dockerfile",
37
+ ".dockerignore",
38
+ "deploy"
39
+ ],
40
+ "license": "MIT",
41
+ "description": "PocketBase add-on: security dashboard, encrypted backups and development extensions",
42
+ "bin": {
43
+ "pocketbase-extension": "bin/pocketbase-extension.mjs"
44
+ },
45
+ "engines": {
46
+ "node": ">=22"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env python3
2
+ """Finite loopback-only rejection smoke; no database writes or credentials."""
3
+ import argparse
4
+ import concurrent.futures
5
+ import json
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+
10
+ parser = argparse.ArgumentParser()
11
+ parser.add_argument('--public', default='http://127.0.0.1:8080')
12
+ parser.add_argument('--operator', default='http://127.0.0.1:8081')
13
+ args = parser.parse_args()
14
+ for origin in (args.public, args.operator):
15
+ url = urllib.parse.urlsplit(origin)
16
+ if url.scheme != 'http' or url.hostname not in ('127.0.0.1', '::1') or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment:
17
+ parser.error('Use a plain loopback HTTP origin')
18
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
19
+ def redirect_request(self, *args, **kwargs):
20
+ return None
21
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
22
+ def get(url):
23
+ try:
24
+ with opener.open(url, timeout=5) as response:
25
+ return response.status, response.read(65536)
26
+ except urllib.error.HTTPError as error:
27
+ return error.code, error.read(65536)
28
+ code, body = get(args.operator + '/edge/status')
29
+ assert code == 200, 'operator status unavailable'
30
+ before = json.loads(body)
31
+ assert before['policyReady'] and before['upstreamHealthy'], 'gateway not ready'
32
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
33
+ codes = list(pool.map(lambda _: get(args.public + '/_/')[0], range(200)))
34
+ assert all(code == 403 for code in codes), set(codes)
35
+ assert get(args.operator + '/edge/healthz')[0] == 204
36
+ assert get(args.operator + '/edge/readyz')[0] == 204
37
+ code, body = get(args.operator + '/edge/status')
38
+ after = json.loads(body)
39
+ assert after['rejected'] - before['rejected'] >= 200
40
+ print(json.dumps({'blockedRequests': len(codes), 'healthCanary': 'passed',
41
+ 'publicAcceptedDelta': after['accepted'] - before['accepted'],
42
+ 'note': 'Zero accepted delta expected on an otherwise idle instance.'}))
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ export GOWORK=off
4
+ module=$(go list -m -f '{{.Version}} {{if .Replace}}REPLACED{{end}}' github.com/pocketbase/pocketbase)
5
+ case "$module" in *REPLACED*) echo 'Compatibility checks must use unmodified upstream PocketBase' >&2; exit 1;; esac
6
+ go test ./...
7
+ go test -race ./edge ./security ./features ./localization ./loadtest ./backups ./settings ./mail ./admin ./otp ./watcher
8
+ go vet ./...
9
+ node --test security/ui/*.test.mjs
10
+ go build -o /tmp/spink-pocketbase-check ./cmd/pocketbase
11
+ go build -o /tmp/spink-edge-check ./cmd/edge
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ export GOWORK=off
4
+ upstream_dir=$(go list -m -f '{{.Dir}}' github.com/pocketbase/pocketbase)
5
+ python3 - "$upstream_dir/plugins/jsvm/internal/types/generated/types.d.ts" <<'PY'
6
+ from pathlib import Path
7
+ import sys
8
+ source = Path(sys.argv[1])
9
+ Path('jsvm/internal/types/generated/types.d.ts').write_text('\n'.join(line.rstrip() for line in source.read_text().splitlines()) + '\n')
10
+ PY
@@ -0,0 +1,94 @@
1
+ # Request protection for custom PocketBase binaries
2
+
3
+ Register once before serving:
4
+
5
+ ```go
6
+ config := security.DefaultConfig()
7
+ config.Mode = "observe" // disabled, observe, enforce
8
+ statePath, err := filepath.Abs(filepath.Join(app.DataDir(), "security", "state.json"))
9
+ if err != nil { return err }
10
+ config.StatePath = statePath
11
+ if err := security.Register(app, config); err != nil { return err }
12
+ ```
13
+
14
+ The standard binary is unchanged. `cmd/pocketbase` registers this plugin and
15
+ accepts `PB_SECURITY_MODE`. `observe` reports decisions without rejecting requests;
16
+ `enforce` applies global and verified-identity token buckets, concurrent request
17
+ limits, separate write/file/realtime lanes, weighted batch size and per-client plus
18
+ aggregate subscription limits. Batch weights and subscription reservations release
19
+ on completion/failure/disconnect. Realtime messages receive a ten-second write
20
+ deadline where supported. Authenticated users behind the same NAT have independent
21
+ identity budgets; a global pre-authentication budget bounds total admission.
22
+ Unauthenticated clients share a source budget. Identity storage is capped and uses
23
+ process-local HMAC fingerprints; excess identities share an overflow bucket.
24
+ Forwarded addresses are considered only for configured immediate proxy peers.
25
+
26
+ Detection evaluates ten-second windows. Three consecutive high-rejection,
27
+ authentication-failure or combined slow/failing windows open a suspected incident.
28
+ Six clear windows resolve it. Legitimate load or client errors can trigger these
29
+ patterns. The detector never installs blocking actions automatically. History is
30
+ capped at 256 incidents. Missing traffic is not independent evidence of recovery;
31
+ these are heuristic state transitions, not validated attack classification.
32
+
33
+ ## Management
34
+
35
+ Open `/_/#/security` through the Security tab after signing into the admin dashboard. It reuses the dashboard SDK session without copying tokens. Extension assets are public; APIs still require a superuser and an immediate peer in `ManagementPeers` (loopback by default). Logout/unmount cancels polling and discards old-session responses.
36
+ Four concurrent management requests have a separate admission lane.
37
+
38
+ | Route | Behavior |
39
+ | --- | --- |
40
+ | GET `/api/security/status` | Mode, counters, active lanes, actions, subscriptions and checkpoint health |
41
+ | GET `/api/security/incidents?limit=50&after=0` | Ascending ID cursor, maximum 100 items, `nextCursor: 0` at end |
42
+ | GET `/api/security/policy/snapshot` | Atomic policy and revision pair for the dashboard |
43
+ | GET `/api/security/policy` | Current configuration; numeric revision in ETag |
44
+ | PUT `/api/security/policy` | Validated replacement with matching numeric If-Match; stale updates return 409 |
45
+ | POST `/api/security/incidents/{id}/acknowledge` | Idempotent acknowledgement |
46
+ | POST `/api/security/actions` | Replace temporary block for one family |
47
+ | DELETE `/api/security/actions/{family}` | Cancel an existing block; missing action returns 404 |
48
+
49
+ Action body: `family`, `reason` (1–200 bytes), RFC3339 `expiresAt` in the next
50
+ 15 minutes. Families: auth, reads, writes, files, realtime, other. Actions apply only
51
+ in enforce mode. Policy changes, acknowledgements, action changes/cancellation log
52
+ operator identity. No endpoint exports a raw request body, token or query.
53
+
54
+ Optional `StatePath` writes atomic private checkpoints every ten seconds, after
55
+ management mutations and on shutdown. Validated policy, revision, incidents and
56
+ unexpired actions survive restart; startup mode always wins. Corrupt/missing state
57
+ falls back to startup configuration, with corruption reported as `storageDegraded`.
58
+ Writes are best effort; an active policy can precede a failed disk write. This is
59
+ not a transactional audit journal. Request counters/identities are process-local.
60
+
61
+ ## Boundaries
62
+
63
+ See [deployment and recovery guidance](../deploy/README.md). Edge
64
+ bandwidth exhaustion requires provider/network protection. No production capacity
65
+ profile, distributed DDoS proof, automatic incident-driven blocking, external
66
+ telemetry exporter or calibrated detector is supplied. Observe mode deliberately
67
+ permits traffic beyond configured limits; it does not claim an enforcement ceiling.
68
+ The original larger acceptance plan remains in the root repository’s `specs/001-security-expansion` with
69
+ unfulfilled release evidence explicitly open.
70
+
71
+ ## Disposable database simulation
72
+
73
+ ```sh
74
+ go run ./cmd/loadtest -sandbox -dry-run -rate 200 -clients 8 -duration 3s -requests 400 -normal-every 10
75
+ go run ./cmd/loadtest -sandbox -mode enforce -rate 200 -clients 8 -duration 3s -requests 400 -normal-every 10
76
+ ```
77
+
78
+ The sandbox creates 5,000 records in a temporary SQLite database, serves read-only
79
+ `pb_sim_records` on an ephemeral loopback port, then stops and removes the fixture.
80
+ Every tenth scheduled request above is a health canary in the same total budget.
81
+ Canaries share the unauthenticated source budget; a rejected canary is a measured
82
+ availability failure, not counted as success. The sandbox binds admission middleware;
83
+ it does not exercise batch hooks or subscription operations.
84
+
85
+ Without `-sandbox`, accepted literal-loopback HTTP paths are `/api/health`,
86
+ `/api/realtime`, and `/api/collections/pb_sim_records/records`. No credentials,
87
+ writes, retries, redirects, proxies, external hosts or query parameters. Maximums:
88
+ 512 clients, 10,000 requests/s, 10 minutes, one million sent requests. Interrupt
89
+ cancels in-flight requests. Successful JSON bodies and SSE connection data are
90
+ validated; bodies are bounded. JSON reports response classes, 429/503, errors,
91
+ dropped scheduling slots, offered/sent counts, canary success and latency buckets.
92
+ `offered = sent + dropped`; dropped slots indicate generator saturation. Duration
93
+ includes request drain/cancellation; normal duration expiry is not an interruption.
94
+ This is a finite local capacity tool, not a distributed attack engine.