@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,61 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "flag"
7
+ "fmt"
8
+ "github.com/spink-dev/pocketbase-extension/loadtest"
9
+ "os"
10
+ "os/signal"
11
+ "time"
12
+ )
13
+
14
+ func main() {
15
+ var c loadtest.Config
16
+ flag.StringVar(&c.Target, "target", "http://127.0.0.1:8090/api/health", "local disposable PocketBase target")
17
+ flag.IntVar(&c.Rate, "rate", 20, "total requests per second")
18
+ flag.IntVar(&c.Clients, "clients", 4, "maximum concurrent clients")
19
+ flag.DurationVar(&c.Duration, "duration", 10*time.Second, "maximum run duration")
20
+ flag.IntVar(&c.MaxRequests, "requests", 200, "maximum total requests")
21
+ sandbox := flag.Bool("sandbox", false, "create a disposable database with 5000 fixture records")
22
+ mode := flag.String("mode", "observe", "sandbox protection mode: disabled, observe, enforce")
23
+ dryRun := flag.Bool("dry-run", false, "validate budgets without traffic or database creation")
24
+ flag.IntVar(&c.NormalEvery, "normal-every", 0, "send a health canary every N scheduled requests, 2..100")
25
+ flag.Parse()
26
+ if *mode != "disabled" && *mode != "observe" && *mode != "enforce" {
27
+ fmt.Fprintln(os.Stderr, "invalid sandbox mode")
28
+ os.Exit(2)
29
+ }
30
+ if *sandbox {
31
+ c.Target = "http://127.0.0.1:8090/api/collections/pb_sim_records/records"
32
+ }
33
+ if err := loadtest.Validate(c); err != nil {
34
+ fmt.Fprintln(os.Stderr, err)
35
+ os.Exit(2)
36
+ }
37
+ if *dryRun {
38
+ json.NewEncoder(os.Stdout).Encode(c)
39
+ return
40
+ }
41
+ if *sandbox {
42
+ target, cleanup, err := startSandbox(*mode)
43
+ if err != nil {
44
+ fmt.Fprintln(os.Stderr, err)
45
+ os.Exit(1)
46
+ }
47
+ defer cleanup()
48
+ c.Target = target
49
+ }
50
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
51
+ defer stop()
52
+ report, err := loadtest.Run(ctx, c)
53
+ if err != nil {
54
+ fmt.Fprintln(os.Stderr, err)
55
+ os.Exit(1)
56
+ }
57
+ if err := json.NewEncoder(os.Stdout).Encode(report); err != nil {
58
+ fmt.Fprintln(os.Stderr, err)
59
+ os.Exit(1)
60
+ }
61
+ }
@@ -0,0 +1,73 @@
1
+ package main
2
+
3
+ import (
4
+ "net/http/httptest"
5
+ "os"
6
+
7
+ "github.com/pocketbase/pocketbase/apis"
8
+ "github.com/pocketbase/pocketbase/core"
9
+ _ "github.com/pocketbase/pocketbase/migrations"
10
+ "github.com/pocketbase/pocketbase/tools/types"
11
+ "github.com/spink-dev/pocketbase-extension/security"
12
+ )
13
+
14
+ func startSandbox(mode string) (string, func(), error) {
15
+ config := security.DefaultConfig()
16
+ config.Mode = mode
17
+ if err := config.Validate(); err != nil {
18
+ return "", nil, err
19
+ }
20
+ dir, err := os.MkdirTemp("", "pb-simulation-*")
21
+ if err != nil {
22
+ return "", nil, err
23
+ }
24
+ app := core.NewBaseApp(core.BaseAppConfig{DataDir: dir})
25
+ cleanup := func() { app.ResetBootstrapState(); os.RemoveAll(dir) }
26
+ if err := app.Bootstrap(); err != nil {
27
+ cleanup()
28
+ return "", nil, err
29
+ }
30
+ collection := core.NewBaseCollection("pb_sim_records")
31
+ collection.ListRule = types.Pointer("")
32
+ collection.ViewRule = types.Pointer("")
33
+ collection.Fields.Add(&core.TextField{Name: "label"}, &core.NumberField{Name: "sequence"})
34
+ if err := app.Save(collection); err != nil {
35
+ cleanup()
36
+ return "", nil, err
37
+ }
38
+ err = app.RunInTransaction(func(tx core.App) error {
39
+ for i := 0; i < 5000; i++ {
40
+ r := core.NewRecord(collection)
41
+ r.Set("label", "simulation fixture")
42
+ r.Set("sequence", i)
43
+ if err := tx.Save(r); err != nil {
44
+ return err
45
+ }
46
+ }
47
+ return nil
48
+ })
49
+ if err != nil {
50
+ cleanup()
51
+ return "", nil, err
52
+ }
53
+ service, err := security.New(config)
54
+ if err != nil {
55
+ cleanup()
56
+ return "", nil, err
57
+ }
58
+ router, err := apis.NewRouter(app)
59
+ if err != nil {
60
+ service.Close()
61
+ cleanup()
62
+ return "", nil, err
63
+ }
64
+ service.Bind(router)
65
+ handler, err := router.BuildMux()
66
+ if err != nil {
67
+ service.Close()
68
+ cleanup()
69
+ return "", nil, err
70
+ }
71
+ server := httptest.NewServer(handler)
72
+ return server.URL + "/api/collections/pb_sim_records/records", func() { server.Close(); service.Close(); cleanup() }, nil
73
+ }
@@ -0,0 +1,20 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "github.com/spink-dev/pocketbase-extension/loadtest"
6
+ "testing"
7
+ "time"
8
+ )
9
+
10
+ func TestSandboxRunsRealDatabase(t *testing.T) {
11
+ target, cleanup, err := startSandbox("observe")
12
+ if err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ defer cleanup()
16
+ report, err := loadtest.Run(context.Background(), loadtest.Config{Target: target, Rate: 10, Clients: 1, Duration: time.Second, MaxRequests: 2, NormalEvery: 2})
17
+ if err != nil || report.Errors != 0 || report.StatusClasses[2] != 2 || report.CanarySucceeded != 1 {
18
+ t.Fatalf("sandbox failed: %+v %v", report, err)
19
+ }
20
+ }
@@ -0,0 +1,78 @@
1
+ package main
2
+
3
+ import (
4
+ "github.com/pocketbase/pocketbase"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "github.com/pocketbase/pocketbase/plugins/migratecmd"
7
+ adapter "github.com/spink-dev/pocketbase-extension"
8
+ _ "github.com/spink-dev/pocketbase-extension/features"
9
+ "github.com/spink-dev/pocketbase-extension/jsvm"
10
+ "github.com/spink-dev/pocketbase-extension/web"
11
+ "log"
12
+ "os"
13
+ "path/filepath"
14
+ "strings"
15
+ )
16
+
17
+ func main() {
18
+ app := pocketbase.New()
19
+ var hooksDir, migrationsDir, publicDir, fallback string
20
+ var watchHooks, automigrate bool
21
+ app.RootCmd.PersistentFlags().StringVar(&hooksDir, "hooksDir", "", "JS hooks directory (default: sibling of data directory)")
22
+ app.RootCmd.PersistentFlags().StringVar(&migrationsDir, "migrationsDir", "", "JS migrations directory (default: sibling of data directory)")
23
+ app.RootCmd.PersistentFlags().BoolVar(&watchHooks, "hooksWatch", false, "Restart on JS hook changes")
24
+ app.RootCmd.PersistentFlags().BoolVar(&automigrate, "automigrate", true, "Generate JS collection migrations")
25
+ app.RootCmd.PersistentFlags().StringVar(&publicDir, "publicDir", "./pb_public", "Static files directory")
26
+ app.RootCmd.PersistentFlags().StringVar(&fallback, "staticFallback", "index.html", "Missing-file fallback; empty disables it")
27
+ if err := app.RootCmd.ParseFlags(os.Args[1:]); err != nil {
28
+ log.Fatal(err)
29
+ }
30
+ config := adapter.DefaultConfig()
31
+ statePath, err := filepath.Abs(filepath.Join(app.DataDir(), "security", "state.json"))
32
+ if err != nil {
33
+ log.Fatal(err)
34
+ }
35
+ config.StatePath = statePath
36
+ if override := os.Getenv("PB_SECURITY_STATE_PATH"); override != "" {
37
+ config.StatePath = override
38
+ }
39
+ if secretPath := os.Getenv("PB_BACKUP_ENCRYPTION_KEY_FILE"); secretPath != "" {
40
+ raw, err := os.ReadFile(secretPath)
41
+ if err != nil {
42
+ log.Fatal("cannot read backup encryption key file")
43
+ }
44
+ key := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r")
45
+ if len(key) < 32 || len(key) > 1024 {
46
+ log.Fatal("backup encryption key must contain 32-1024 bytes")
47
+ }
48
+ if err := os.Setenv("PB_BACKUP_ENCRYPTION_KEY", key); err != nil {
49
+ log.Fatal(err)
50
+ }
51
+ }
52
+ if mode := os.Getenv("PB_SECURITY_MODE"); mode != "" {
53
+ config.Mode = mode
54
+ }
55
+ if value := os.Getenv("PB_SECURITY_TRUSTED_PEERS"); value != "" {
56
+ config.TrustedPeers = strings.Split(value, ",")
57
+ }
58
+ if value := os.Getenv("PB_SECURITY_MANAGEMENT_PEERS"); value != "" {
59
+ config.ManagementPeers = strings.Split(value, ",")
60
+ }
61
+ if value := os.Getenv("PB_SECURITY_OPERATOR_PEERS"); value != "" {
62
+ config.OperatorPeers = strings.Split(value, ",")
63
+ }
64
+ if err := adapter.Register(app, config); err != nil {
65
+ log.Fatal(err)
66
+ }
67
+ jsvm.MustRegister(app, jsvm.Config{HooksDir: hooksDir, MigrationsDir: migrationsDir, HooksWatch: watchHooks, HooksPoolSize: 15})
68
+ migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{Dir: migrationsDir, Automigrate: automigrate, TemplateLang: migratecmd.TemplateLangJS})
69
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
70
+ if !e.Router.HasRoute("GET", "/{path...}") {
71
+ e.Router.GET("/{path...}", web.StaticWithOptions(os.DirFS(publicDir), web.StaticOptions{Fallback: fallback}))
72
+ }
73
+ return e.Next()
74
+ })
75
+ if err := app.Start(); err != nil {
76
+ log.Fatal(err)
77
+ }
78
+ }
@@ -0,0 +1,148 @@
1
+ # Edge-protected PocketBase
2
+
3
+ Two containers: the public gateway rejects traffic before PocketBase; the database
4
+ has no published port. Use this as a single-host deployment template, with upstream
5
+ TLS/DDoS protection appropriate to your hosting provider.
6
+
7
+ ```sh
8
+ cd /Users/x47base/git/spink-dev_pocketbase-extension
9
+ docker compose -f deploy/compose.yaml up --build -d --wait
10
+ docker compose -f deploy/compose.yaml logs pocketbase
11
+ ```
12
+
13
+ Complete the first-run superuser setup link through **http://127.0.0.1:8081/_/**
14
+ (replace the origin in the container's printed link with that address). There are
15
+ no default credentials. This creates a **new database** in named Docker volumes;
16
+ your existing development instance on 8090 and its admin account are untouched.
17
+
18
+ | Address | Purpose |
19
+ |---|---|
20
+ | `http://127.0.0.1:8080` | Public API: explicitly allowed collections only |
21
+ | `http://127.0.0.1:8081/_/` | Native admin, Security, Backups, x47base extensions |
22
+ | `http://127.0.0.1:8081/edge/status` | Gateway accepted/rejected counts, active work, readiness |
23
+ | `http://127.0.0.1:8081/edge/healthz` | Gateway process liveness; no database call |
24
+ | `http://127.0.0.1:8081/edge/readyz` | Policy validity plus cached upstream health |
25
+
26
+ The operator port is loopback-only and keeps native PocketBase authentication.
27
+ Use an SSH tunnel for remote administration. Do not publish port 8081 publicly;
28
+ its diagnostics are intentionally local, without a second login mechanism.
29
+
30
+ ```mermaid
31
+ flowchart LR
32
+ Internet[Public client] --> Edge[Gateway checks and budgets]
33
+ Edge --> DB[Private PocketBase]
34
+ Operator[Local admin or SSH tunnel] --> Lane[Reserved operator lane]
35
+ Lane --> DB
36
+ DB --> State[Security checkpoint volume]
37
+ State -. read only, one second poll .-> Edge
38
+ App[Versioned migrations and hooks] --> DB
39
+ ```
40
+
41
+ ## Ship your schema and application
42
+
43
+ 1. Replace `deploy/app/migrations/1789000000_notes.js` **before first deployment**
44
+ with reviewed PocketBase JS migrations defining your collections, rules and
45
+ indexes. The example creates private `notes` with title/body fields. Native
46
+ migration history applies each version once. Add new migrations for subsequent
47
+ changes; do not edit already-applied versions.
48
+ 2. Place trusted JS hooks in `deploy/app/hooks` and static assets in
49
+ `deploy/app/public`. These are baked into the image, with no writable hooks or
50
+ automatic migration generation in production. Static assets are operator-only
51
+ under the default public allowlist; host the public frontend separately.
52
+ 3. List the permitted collection names (and IDs if clients use IDs) in
53
+ `deploy/edge.json` → `collections`. This grants network reachability only;
54
+ native collection rules still control data access. Add auth collections too.
55
+ 4. Rebuild and deploy. Review migrations against a disposable restored copy first.
56
+ Do not run two PocketBase containers against one data volume. Take a backup
57
+ before a schema upgrade; rollback may require the old binary and database copy.
58
+
59
+ ## Pre-database protection
60
+
61
+ `deploy/edge.json` is read every second. Use `blockedCIDRs` for IP/CIDR blocks and
62
+ `blockedPaths` for literal path-prefix blocks. Malformed policy or a missing,
63
+ invalid, or older-than-30-seconds security checkpoint denies public traffic with
64
+ 503. Correcting the file automatically recovers. When replacing bind-mounted
65
+ files atomically, recreate the edge container so Docker mounts the new inode:
66
+ `docker compose -f deploy/compose.yaml up -d --force-recreate edge`.
67
+
68
+ Temporary family blocks made in **Security → Protection** are enforced at the
69
+ edge from the last successfully persisted checkpoint, within approximately one
70
+ second. They apply in **enforce** mode and expire at their configured time. Requests
71
+ already admitted are not recalled. If checkpoint writes fail, the old policy can
72
+ remain active for up to 30 seconds before public access fails closed. Admin access
73
+ remains available to diagnose and recover. Preserve the separate security volume
74
+ when replacing the database; it is not included in database backup archives.
75
+
76
+ Defaults: 50 public requests/second (burst 100), 20 per client (burst 40), 32 active
77
+ requests, 1MiB bodies, 4096 tracked clients. No waiting queue is added. New identities
78
+ are denied when the bounded table is full; idle entries expire after a minute.
79
+ Public bodies, including chunked bodies, are read and size-checked **before** any
80
+ upstream side effect. Aggregate configured body buffering is capped at 128MiB.
81
+ Public connections cap at 256, headers at 16KiB, header reads at 3 seconds, request
82
+ reads at 10 seconds, writes at 30 seconds. Realtime streams must reconnect after
83
+ the public write deadline; they share the public concurrency budget.
84
+
85
+ The gateway denies public admin/settings/backups/schema/batch routes, ambiguous
86
+ encoded paths, unknown collections and all unlisted custom routes. Public file
87
+ access is limited to allowed collections; native authorization still runs. The
88
+ separate `/api/files/token` endpoint is not exposed by this template. Deployments
89
+ using batch, custom routes, large uploads or longer streams need reviewed gateway
90
+ changes/limits; there is no catch-all proxy bypass.
91
+
92
+ The runtime-only `PB_SECURITY_OPERATOR_PEERS` list opts the private gateway into
93
+ PocketBase's bounded management lane using `X-Spink-Operator`. Public requests have
94
+ that marker stripped. Native authentication/authorization still run, including for
95
+ operator requests. Do not enable this on a proxy that forwards user-supplied markers.
96
+ The setting is deliberately absent from editable/persisted policy JSON.
97
+
98
+ Client-supplied forwarding headers are replaced. `trustedProxies` is empty by
99
+ default. When putting a TLS proxy in front, restrict the public port at the host
100
+ firewall to that proxy and list only its actual CIDRs; it must sanitize incoming
101
+ forwarding headers. Never trust all addresses. Docker Desktop may present a shared
102
+ host IP, so inspect your production network before relying on per-client limits.
103
+ PocketBase trusts only the gateway's fixed private IP. If changing the subnet,
104
+ change both Compose IPs and the PocketBase peer environment variables. On an
105
+ existing database, review persisted Security policy peer lists as well.
106
+
107
+ ## Deployment boundaries and secrets
108
+
109
+ Containers run non-root, with read-only roots, dropped capabilities, resource and
110
+ log limits. The backend network is internal. It has **no external SMTP/S3/OAuth
111
+ provider access by default**; add a controlled egress network/firewall if needed.
112
+ Do not remove the private network or publish the database port to fix egress.
113
+ The default public bind is also loopback. Exposing it uses `PB_PUBLIC_BIND` and
114
+ requires an external TLS/network edge; this template itself serves HTTP.
115
+
116
+ Optional encrypted-backup key via a mounted Docker secret:
117
+
118
+ ```sh
119
+ export PB_BACKUP_KEY_FILE=/absolute/protected/backup-key
120
+ # File contains an independently generated 32–1024 byte secret, retained offline.
121
+ docker compose -f deploy/compose.yaml -f deploy/compose.secrets.yaml up -d --build
122
+ ```
123
+
124
+ Then enable encryption in Settings → x47base extensions. The default key environment
125
+ name remains `PB_BACKUP_ENCRYPTION_KEY`. Do not commit keys or put them in images.
126
+ Docker Compose secret files still require secure host storage. Custom images
127
+ should pin approved base-image digests and pass your image scanning policy.
128
+
129
+ Health checks report health; Docker restart policy restarts exited processes,
130
+ not merely unhealthy ones. Rejection reduces database work; it cannot guarantee
131
+ availability during link saturation, host exhaustion, disk failure or costly
132
+ allowed queries. Use provider/CDN DDoS protection, backups and external monitoring.
133
+ Gateway rejections appear in `/edge/status`; PocketBase incidents only describe
134
+ traffic that reaches PocketBase, so the two counts intentionally differ.
135
+
136
+ ## Verification and stopping
137
+
138
+ ```sh
139
+ go test -race ./edge
140
+ # Bounded local smoke; repeated blocked admin paths never reach PocketBase:
141
+ python3 scripts/check-edge.py --public http://127.0.0.1:8080 --operator http://127.0.0.1:8081
142
+ # Stop without deleting data:
143
+ docker compose -f deploy/compose.yaml down
144
+ ```
145
+
146
+ `down -v` deletes database and security volumes; do not use it for a real deployment.
147
+ Compose behavior follows the [official services](https://docs.docker.com/reference/compose-file/services/)
148
+ and [network documentation](https://docs.docker.com/reference/compose-file/networks/).
@@ -0,0 +1,2 @@
1
+ Place trusted application *.pb.js hooks here. Rebuild the image to deploy changes.
2
+ Hooks execute server-side code with database access; only ship reviewed code.
@@ -0,0 +1,16 @@
1
+ // Example schema: private until application-specific record rules are added.
2
+ migrate((app) => {
3
+ const collection = new Collection({
4
+ name: "notes",
5
+ type: "base",
6
+ fields: [
7
+ { name: "title", type: "text", required: true, max: 200 },
8
+ { name: "body", type: "text", max: 10000 }
9
+ ],
10
+ listRule: null, viewRule: null, createRule: null,
11
+ updateRule: null, deleteRule: null
12
+ });
13
+ app.save(collection);
14
+ }, (app) => {
15
+ app.delete(app.findCollectionByNameOrId("notes"));
16
+ });
@@ -0,0 +1,2 @@
1
+ Place application static assets here. They are available through the operator
2
+ listener only by default. The public gateway exposes allowed API routes only.
@@ -0,0 +1,8 @@
1
+ services:
2
+ pocketbase:
3
+ environment:
4
+ PB_BACKUP_ENCRYPTION_KEY_FILE: /run/secrets/backup_key
5
+ secrets: [backup_key]
6
+ secrets:
7
+ backup_key:
8
+ file: ${PB_BACKUP_KEY_FILE:?Set PB_BACKUP_KEY_FILE to your protected key file}
@@ -0,0 +1,68 @@
1
+ name: spink-pocketbase
2
+ services:
3
+ pocketbase:
4
+ build:
5
+ context: ..
6
+ target: pocketbase
7
+ image: spink-pocketbase:local
8
+ restart: unless-stopped
9
+ read_only: true
10
+ cap_drop: [ALL]
11
+ security_opt: [no-new-privileges:true]
12
+ pids_limit: 128
13
+ mem_limit: 512m
14
+ cpus: 1.5
15
+ tmpfs: ["/tmp:size=64m,mode=1777"]
16
+ environment:
17
+ PB_SECURITY_MODE: enforce
18
+ PB_SECURITY_STATE_PATH: /state/state.json
19
+ PB_SECURITY_OPERATOR_PEERS: 172.30.80.2/32
20
+ PB_SECURITY_TRUSTED_PEERS: 172.30.80.2/32
21
+ PB_SECURITY_MANAGEMENT_PEERS: 172.30.80.2/32,127.0.0.0/8,::1/128
22
+ GOMEMLIMIT: 400MiB
23
+ volumes:
24
+ - data:/pb_data
25
+ - security:/state
26
+ networks:
27
+ backend:
28
+ ipv4_address: 172.30.80.3
29
+ logging: &logs
30
+ driver: json-file
31
+ options: {max-size: 10m, max-file: "3"}
32
+ edge:
33
+ build:
34
+ context: ..
35
+ target: edge
36
+ image: spink-pocketbase-edge:local
37
+ restart: unless-stopped
38
+ read_only: true
39
+ cap_drop: [ALL]
40
+ security_opt: [no-new-privileges:true]
41
+ pids_limit: 128
42
+ mem_limit: 256m
43
+ cpus: 1.0
44
+ environment: {GOMEMLIMIT: 200MiB}
45
+ ports:
46
+ - "${PB_PUBLIC_BIND:-127.0.0.1}:${PB_PUBLIC_PORT:-8080}:8080"
47
+ - "127.0.0.1:${PB_OPERATOR_PORT:-8081}:8081"
48
+ volumes:
49
+ - ./edge.json:/etc/spink/edge.json:ro
50
+ - security:/state:ro
51
+ depends_on:
52
+ pocketbase:
53
+ condition: service_healthy
54
+ networks:
55
+ frontend: {}
56
+ backend:
57
+ ipv4_address: 172.30.80.2
58
+ logging: *logs
59
+ networks:
60
+ frontend: {}
61
+ backend:
62
+ internal: true
63
+ ipam:
64
+ config:
65
+ - subnet: 172.30.80.0/24
66
+ volumes:
67
+ data: {}
68
+ security: {}
@@ -0,0 +1,13 @@
1
+ {
2
+ "collections": ["notes"],
3
+ "blockedCIDRs": [],
4
+ "blockedPaths": [],
5
+ "trustedProxies": [],
6
+ "rate": 50,
7
+ "burst": 100,
8
+ "clientRate": 20,
9
+ "clientBurst": 40,
10
+ "maxClients": 4096,
11
+ "maxConcurrent": 32,
12
+ "maxBodyBytes": 1048576
13
+ }