@x47base/pocketbase-addon 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/Dockerfile +9 -1
  2. package/FEATURES.md +17 -0
  3. package/MIGRATION.md +51 -2
  4. package/NOTICE.md +2 -0
  5. package/README.md +53 -13
  6. package/SECURITY-REVIEW.md +96 -0
  7. package/VERIFY.md +34 -0
  8. package/backups/concurrency_test.go +43 -0
  9. package/backups/register.go +11 -0
  10. package/bin/launcher.test.mjs +31 -0
  11. package/bin/pocketbase-extension.mjs +17 -3
  12. package/cmd/edge/main.go +28 -0
  13. package/cmd/gateway/main.go +84 -0
  14. package/cmd/hosting/main.go +27 -0
  15. package/cmd/pocketbase/main.go +12 -0
  16. package/deploy/README.md +35 -6
  17. package/deploy/compose.yaml +5 -0
  18. package/deploy/edge.json +6 -2
  19. package/edge/gateway.go +110 -35
  20. package/edge/openapi.json +226 -1
  21. package/edge/policy.go +23 -12
  22. package/edge/telemetry.go +187 -0
  23. package/edge/telemetry_test.go +181 -0
  24. package/hosting/README.md +62 -0
  25. package/hosting/backups.go +381 -0
  26. package/hosting/backups_test.go +81 -0
  27. package/hosting/blueprint.example.json +14 -0
  28. package/hosting/blueprint.go +242 -0
  29. package/hosting/blueprint_test.go +98 -0
  30. package/hosting/config.go +126 -0
  31. package/hosting/deploy/dns.example.json +1 -0
  32. package/hosting/docs/DEPLOYMENT.md +98 -0
  33. package/hosting/hosting.example.json +1 -0
  34. package/hosting/local_target_test.go +29 -0
  35. package/hosting/scripts/dns.mjs +116 -0
  36. package/hosting/scripts/routes.mjs +39 -0
  37. package/hosting/ui/main.js +36 -0
  38. package/hosting/ui/page.css +86 -0
  39. package/hosting/ui/page.js +112 -0
  40. package/multinode/README.md +87 -0
  41. package/multinode/gateway.docker.json +1 -0
  42. package/multinode/gateway.example.json +1 -0
  43. package/multinode/gateway.go +347 -0
  44. package/multinode/gateway_test.go +217 -0
  45. package/multinode/security_regression_test.go +106 -0
  46. package/package.json +11 -6
  47. package/scripts/check-edge.py +21 -4
  48. package/scripts/check.sh +5 -2
  49. package/security/README.md +42 -9
  50. package/security/config.go +4 -0
  51. package/security/edge_telemetry.go +123 -0
  52. package/security/edge_telemetry_test.go +87 -0
  53. package/security/management.go +3 -1
  54. package/security/openapi.json +269 -0
  55. package/security/security.go +37 -13
  56. package/security/security_test.go +54 -0
  57. package/security/state.go +12 -7
  58. package/security/ui/dashboard.css +9 -2
  59. package/security/ui/dashboard.js +68 -20
  60. package/security/ui/main.js +16 -4
  61. package/security/ui/model.js +23 -1
  62. package/security/ui/model.test.mjs +12 -0
@@ -0,0 +1,62 @@
1
+ # Hosting — @x47base/pocketbase-addon
2
+
3
+ Optional named backup targets, a native **Hosting** dashboard and deployment rendering. Included in the [combined npm package](../README.md); no separate hosting package is needed.
4
+
5
+ ## Enable backup policies
6
+
7
+ From your application directory:
8
+
9
+ ```sh
10
+ npx pocketbase-addon init
11
+ HOSTING_ADDON_CONFIG=./hosting.json PB_SECURITY_MODE=enforce \
12
+ npx pocketbase-addon serve --http=127.0.0.1:8090 --dir=./pb_data
13
+ ```
14
+
15
+ The initial configuration uses `{"targets":[{"key":"native","kind":"native"}]}`. The native destination follows PocketBase's backup settings. For named local or S3 targets, copy and edit `node_modules/@x47base/pocketbase-addon/hosting/hosting.example.json`. Remove unused targets. Paths must be absolute; local targets must be outside the data directory or under its excluded `backups` directory. Symlink aliases are checked. S3 endpoints must use HTTPS, with bucket, region and credential environment references configured on the server.
16
+
17
+ Administrators select named targets in **Hosting**; client input cannot select arbitrary endpoints or paths. `hosting_backup_policies` and `hosting_backup_runs` default to superuser-only access. Policies support intervals of 5–43,200 minutes and retention of 1–1,000 snapshots. Schedules run once a minute; missed executions coalesce. Manual execution also advances the next scheduled run. Overlapping backup/restore operations are rejected. Configuration target changes require a restart; policy changes do not.
18
+
19
+ Named S3 targets require the base encryption hook and refuse plaintext writes. Supply `PB_BACKUP_ENCRYPTION_KEY` securely and enable encryption in **Settings → x47base extensions**. A completed run verifies that the snapshot object exists; it does not prove restoration. Retention deletes only that policy's snapshot prefix after the new backup exists. Interrupted runs become `unknown` on restart. Inspect the destination before retrying.
20
+
21
+ Backups cover the entire owner database and its local files. They do not include externally stored record files or a separate Vendure/PostgreSQL database. Retrieve alternate-target snapshots securely and use the native backup import/recovery flow on a disposable owner to test recovery. Review restored policies before enabling schedules against production backup destinations.
22
+
23
+ ## Embed in Go
24
+
25
+ Use the npm-installed source replacement described in [MIGRATION.md](../MIGRATION.md), then register after the base add-on and before `app.Start()`:
26
+
27
+ ```go
28
+ import hosting "github.com/spink-dev/pocketbase-extension/hosting"
29
+
30
+ config, err := hosting.Load(os.Getenv("HOSTING_ADDON_CONFIG"))
31
+ if err != nil { log.Fatal(err) }
32
+ if _, err := hosting.Register(app, config); err != nil { log.Fatal(err) }
33
+ ```
34
+
35
+ Do not register hosting both manually and through the bundled CLI.
36
+
37
+ ## Render a commerce deployment
38
+
39
+ This renderer targets a **separate commerce application**. It requires a Vendure integration, custom storefront/domain-guard endpoints and built images. The combined base server does not implement those business endpoints. Generic PocketBase deployments can use the [base deployment guide](../deploy/README.md).
40
+
41
+ ```sh
42
+ cp node_modules/@x47base/pocketbase-addon/hosting/blueprint.example.json blueprint.json
43
+ # Edit image tags, domains, email, storage/ingress classes, issuer and egress CIDRs.
44
+ npx pocketbase-addon hosting -blueprint ./blueprint.json -out ./generated
45
+ ```
46
+
47
+ Rendering creates Compose/Kubernetes JSON, gateway routes, Caddy TLS configuration and a DNS plan. It creates no cloud resources or running services. See [deployment contracts and recovery](docs/DEPLOYMENT.md) for the required files, secrets and image behavior. Build the gateway image from the installed package root with `docker build --target gateway -t your-registry/gateway:your-version node_modules/@x47base/pocketbase-addon`.
48
+
49
+ Public ingress reaches only the gateway; administration and database origins remain private. Never scale an owner above one writer per data directory. Kubernetes templates require an enforcing CNI, CSI storage supporting `ReadWriteOncePod`, ingress, DNS and a TLS issuer. Provider deployment and restore drills need environment-specific validation.
50
+
51
+ ### Verified domain export and DNS
52
+
53
+ The separate commerce application must validate domain ownership and enforce live store/domain bindings on the origin. Then:
54
+
55
+ ```sh
56
+ # Supply HOSTING_PB_TOKEN via the environment, never committed configuration.
57
+ node node_modules/@x47base/pocketbase-addon/hosting/scripts/routes.mjs https://management.example.com blueprint.json routed.json
58
+ npx pocketbase-addon hosting -blueprint routed.json -out generated
59
+ node node_modules/@x47base/pocketbase-addon/hosting/scripts/dns.mjs dns.json plan
60
+ ```
61
+
62
+ Start `dns.json` from the packaged `hosting/deploy/dns.example.json` and replace all placeholders. DNS scripts support Cloudflare or Route 53. The explicit `apply` argument makes provider changes; review plans first. Cloudflare uses a named token environment variable; Route 53 uses the AWS CLI credential chain. No credentials are stored in PocketBase. DNS changes and gateway route reloads are separate operations.
@@ -0,0 +1,381 @@
1
+ package hosting
2
+
3
+ import (
4
+ "context"
5
+ "embed"
6
+ "errors"
7
+ "fmt"
8
+ "github.com/pocketbase/dbx"
9
+ "github.com/pocketbase/pocketbase/apis"
10
+ "github.com/pocketbase/pocketbase/core"
11
+ "github.com/pocketbase/pocketbase/tools/filesystem"
12
+ "github.com/pocketbase/pocketbase/tools/hook"
13
+ "io/fs"
14
+ "os"
15
+ "path/filepath"
16
+ "sort"
17
+ "strings"
18
+ "sync"
19
+ "time"
20
+ )
21
+
22
+ const PoliciesCollection = "hosting_backup_policies"
23
+ const RunsCollection = "hosting_backup_runs"
24
+
25
+ //go:embed ui/*
26
+ var assets embed.FS
27
+
28
+ type targetKey struct{}
29
+ type targetApp struct {
30
+ core.App
31
+ target Target
32
+ }
33
+
34
+ func (a *targetApp) NewBackupsFilesystem() (*filesystem.System, error) {
35
+ return openTarget(a.App, a.target)
36
+ }
37
+ func openTarget(app core.App, t Target) (*filesystem.System, error) {
38
+ switch t.Kind {
39
+ case "native":
40
+ return app.NewBackupsFilesystem()
41
+ case "local":
42
+ if err := validateLocalTarget(app.DataDir(), t.Path); err != nil {
43
+ return nil, err
44
+ }
45
+ if err := os.MkdirAll(t.Path, 0700); err != nil {
46
+ return nil, err
47
+ }
48
+ return filesystem.NewLocal(t.Path)
49
+ case "s3":
50
+ if os.Getenv(t.AccessKeyEnv) == "" || os.Getenv(t.SecretEnv) == "" {
51
+ return nil, errors.New("backup credentials unavailable")
52
+ }
53
+ f, err := filesystem.NewS3(t.Bucket, t.Region, t.Endpoint, os.Getenv(t.AccessKeyEnv), os.Getenv(t.SecretEnv), t.PathStyle)
54
+ if err != nil {
55
+ return nil, err
56
+ }
57
+ f.OnNewWriter().BindFunc(func(e *filesystem.NewWriterEvent) error {
58
+ if !strings.HasSuffix(e.FileKey, ".zip.age") {
59
+ return errors.New("remote backups require the base add-on's encrypted backup mode")
60
+ }
61
+ return e.Next()
62
+ })
63
+ return f, nil
64
+ }
65
+ return nil, errors.New("unknown target")
66
+ }
67
+
68
+ type Service struct {
69
+ owner context.Context
70
+ app core.App
71
+ config Config
72
+ runMu sync.Mutex
73
+ backupMu sync.Mutex
74
+ }
75
+
76
+ func (s *Service) target(key string) (Target, error) {
77
+ for _, t := range s.config.Targets {
78
+ if t.Key == key {
79
+ return t, nil
80
+ }
81
+ }
82
+ return Target{}, errors.New("unknown target")
83
+ }
84
+ func ensureCollections(app core.App) error {
85
+ return app.RunInTransaction(func(tx core.App) error {
86
+ if _, err := tx.FindCollectionByNameOrId(PoliciesCollection); err != nil {
87
+ c := core.NewBaseCollection(PoliciesCollection)
88
+ c.Fields.Add(&core.TextField{Name: "name", Required: true, Max: 128}, &core.TextField{Name: "target", Required: true, Max: 64}, &core.BoolField{Name: "enabled"}, &core.NumberField{Name: "intervalMinutes", Required: true, OnlyInt: true, Min: floatPointer(5), Max: floatPointer(43200)}, &core.NumberField{Name: "keep", Required: true, OnlyInt: true, Min: floatPointer(1), Max: floatPointer(1000)}, &core.DateField{Name: "nextRun"}, &core.TextField{Name: "lastStatus", Max: 32})
89
+ if err = tx.Save(c); err != nil {
90
+ return err
91
+ }
92
+ }
93
+ if _, err := tx.FindCollectionByNameOrId(RunsCollection); err != nil {
94
+ p, err := tx.FindCollectionByNameOrId(PoliciesCollection)
95
+ if err != nil {
96
+ return err
97
+ }
98
+ c := core.NewBaseCollection(RunsCollection)
99
+ c.Fields.Add(&core.RelationField{Name: "policy", Required: true, CollectionId: p.Id, MaxSelect: 1}, &core.TextField{Name: "target", Required: true, Max: 64}, &core.TextField{Name: "status", Required: true, Max: 32}, &core.TextField{Name: "key", Max: 256}, &core.TextField{Name: "message", Max: 1024}, &core.AutodateField{Name: "created", OnCreate: true}, &core.DateField{Name: "finished"})
100
+ c.AddIndex("idx_hosting_backup_runs", false, "policy, created", "")
101
+ return tx.Save(c)
102
+ }
103
+ return nil
104
+ })
105
+ }
106
+ func floatPointer(v float64) *float64 { return &v }
107
+ func Register(app core.App, c Config) (*Service, error) {
108
+ if err := c.Validate(); err != nil {
109
+ return nil, err
110
+ }
111
+ s := &Service{app: app, config: c}
112
+ ownerContext, stop := context.WithCancel(context.Background())
113
+ s.owner = ownerContext
114
+ app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { stop(); s.runMu.Lock(); defer s.runMu.Unlock(); return e.Next() })
115
+ setup := func() error {
116
+ for _, target := range c.Targets {
117
+ if target.Kind == "local" {
118
+ if err := validateLocalTarget(app.DataDir(), target.Path); err != nil {
119
+ return err
120
+ }
121
+ }
122
+ }
123
+ if err := ensureCollections(app); err != nil {
124
+ return err
125
+ }
126
+ rows, err := app.FindRecordsByFilter(RunsCollection, "status='running'", "", 0, 0)
127
+ if err != nil {
128
+ return err
129
+ }
130
+ for _, r := range rows {
131
+ r.Set("status", "unknown")
132
+ r.Set("message", "Owner restarted; inspect the destination before retrying")
133
+ if err = app.Save(r); err != nil {
134
+ return err
135
+ }
136
+ }
137
+ return nil
138
+ }
139
+ if app.IsBootstrapped() {
140
+ if err := setup(); err != nil {
141
+ return nil, err
142
+ }
143
+ }
144
+ app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
145
+ if err := e.Next(); err != nil {
146
+ return err
147
+ }
148
+ return setup()
149
+ })
150
+ app.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{Priority: -1000, Func: func(e *core.BackupEvent) error {
151
+ if !s.backupMu.TryLock() {
152
+ return errors.New("backup operation already running")
153
+ }
154
+ defer s.backupMu.Unlock()
155
+ if t, ok := e.Context.Value(targetKey{}).(Target); ok {
156
+ original := e.App
157
+ e.App = &targetApp{App: original, target: t}
158
+ defer func() { e.App = original }()
159
+ }
160
+ return e.Next()
161
+ }})
162
+ app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{Priority: -1000, Func: func(e *core.BackupEvent) error {
163
+ if !s.backupMu.TryLock() {
164
+ return errors.New("backup operation already running")
165
+ }
166
+ defer s.backupMu.Unlock()
167
+ return e.Next()
168
+ }})
169
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
170
+ bundle, err := fs.Sub(assets, "ui")
171
+ if err != nil {
172
+ return err
173
+ }
174
+ e.UIExtensions = append(e.UIExtensions, core.UIExtension{Name: "x47-hosting", FS: bundle})
175
+ g := e.Router.Group("/api/hosting").Bind(apis.RequireSuperuserAuth(), apis.BodyLimit(4096))
176
+ g.BindFunc(func(e *core.RequestEvent) error {
177
+ e.Response.Header().Set("Cache-Control", "no-store")
178
+ return e.Next()
179
+ })
180
+ g.GET("/backups", func(e *core.RequestEvent) error {
181
+ policies, err := app.FindRecordsByFilter(PoliciesCollection, "", "name", 1000, 0)
182
+ if err != nil {
183
+ return err
184
+ }
185
+ runs, err := app.FindRecordsByFilter(RunsCollection, "", "-created", 50, 0)
186
+ if err != nil {
187
+ return err
188
+ }
189
+ targets := []map[string]string{}
190
+ for _, t := range c.Targets {
191
+ targets = append(targets, map[string]string{"key": t.Key, "kind": t.Kind})
192
+ }
193
+ return e.JSON(200, map[string]any{"policies": policies, "runs": runs, "targets": targets})
194
+ })
195
+ save := func(e *core.RequestEvent) error {
196
+ var input struct {
197
+ Name, Target string
198
+ Enabled bool
199
+ IntervalMinutes, Keep int
200
+ }
201
+ if e.BindBody(&input) != nil {
202
+ return e.BadRequestError("Invalid policy", nil)
203
+ }
204
+ if _, err := s.target(input.Target); err != nil {
205
+ return e.BadRequestError("Unknown target", nil)
206
+ }
207
+ var record *core.Record
208
+ var err error
209
+ if id := e.Request.PathValue("id"); id != "" {
210
+ record, err = app.FindRecordById(PoliciesCollection, id)
211
+ } else {
212
+ var coll *core.Collection
213
+ coll, err = app.FindCollectionByNameOrId(PoliciesCollection)
214
+ if err == nil {
215
+ record = core.NewRecord(coll)
216
+ }
217
+ }
218
+ if err != nil {
219
+ return e.NotFoundError("Policy not found", nil)
220
+ }
221
+ record.Set("name", input.Name)
222
+ record.Set("target", input.Target)
223
+ record.Set("enabled", input.Enabled)
224
+ record.Set("intervalMinutes", input.IntervalMinutes)
225
+ record.Set("keep", input.Keep)
226
+ record.Set("nextRun", time.Now().Add(time.Duration(input.IntervalMinutes)*time.Minute))
227
+ if err = app.Save(record); err != nil {
228
+ return e.BadRequestError("Invalid policy", err)
229
+ }
230
+ return e.JSON(200, record)
231
+ }
232
+ g.POST("/backups/policies", save)
233
+ g.PUT("/backups/policies/{id}", save)
234
+ g.POST("/backups/policies/{id}/run", func(e *core.RequestEvent) error {
235
+ run, err := s.Run(e.Request.Context(), e.Request.PathValue("id"))
236
+ if err != nil {
237
+ return e.JSON(409, map[string]any{"message": err.Error(), "run": run})
238
+ }
239
+ return e.JSON(200, run)
240
+ })
241
+ app.Cron().MustAdd("hosting-backup-policies", "* * * * *", func() { s.Tick(ownerContext) })
242
+ return e.Next()
243
+ })
244
+ return s, nil
245
+ }
246
+ func (s *Service) Tick(ctx context.Context) {
247
+ rows, err := s.app.FindRecordsByFilter(PoliciesCollection, "enabled=true && nextRun<={:now}", "nextRun", 100, 0, dbx.Params{"now": time.Now().UTC().Format("2006-01-02 15:04:05.000Z")})
248
+ if err != nil {
249
+ return
250
+ }
251
+ for _, p := range rows {
252
+ if ctx.Err() != nil {
253
+ return
254
+ }
255
+ _, _ = s.run(ctx, p.Id, true)
256
+ }
257
+ }
258
+ func (s *Service) Run(ctx context.Context, id string) (*core.Record, error) {
259
+ return s.run(ctx, id, false)
260
+ }
261
+ func (s *Service) run(ctx context.Context, id string, scheduled bool) (*core.Record, error) {
262
+ if !s.runMu.TryLock() {
263
+ return nil, errors.New("a scheduled backup is already running")
264
+ }
265
+ defer s.runMu.Unlock()
266
+ if s.owner.Err() != nil {
267
+ return nil, errors.New("owner is stopping")
268
+ }
269
+ p, err := s.app.FindRecordById(PoliciesCollection, id)
270
+ if err != nil {
271
+ return nil, err
272
+ }
273
+ if scheduled && (!p.GetBool("enabled") || p.GetDateTime("nextRun").Time().After(time.Now())) {
274
+ return nil, nil
275
+ }
276
+ target, err := s.target(p.GetString("target"))
277
+ if err != nil {
278
+ return nil, err
279
+ }
280
+ c, err := s.app.FindCollectionByNameOrId(RunsCollection)
281
+ if err != nil {
282
+ return nil, err
283
+ }
284
+ run := core.NewRecord(c)
285
+ run.Set("policy", id)
286
+ run.Set("target", target.Key)
287
+ run.Set("status", "running")
288
+ prefix := "hosting_" + id + "_"
289
+ name := prefix + time.Now().UTC().Format("20060102T150405.000000000") + ".zip"
290
+ run.Set("key", name)
291
+ err = s.app.RunInTransaction(func(tx core.App) error {
292
+ current, err := tx.FindRecordById(PoliciesCollection, id)
293
+ if err != nil {
294
+ return err
295
+ }
296
+ current.Set("nextRun", time.Now().Add(time.Duration(current.GetInt("intervalMinutes"))*time.Minute))
297
+ current.Set("lastStatus", "running")
298
+ if err := tx.Save(current); err != nil {
299
+ return err
300
+ }
301
+ return tx.Save(run)
302
+ })
303
+ if err != nil {
304
+ return nil, err
305
+ }
306
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
307
+ defer cancel()
308
+ stopOwner := context.AfterFunc(s.owner, cancel)
309
+ defer stopOwner()
310
+ err = s.app.CreateBackup(context.WithValue(ctx, targetKey{}, target), name)
311
+ if err == nil {
312
+ err = s.retain(ctx, target, prefix, name, p.GetInt("keep"), run)
313
+ }
314
+ status, message := "completed", ""
315
+ if err != nil {
316
+ status = "failed"
317
+ message = "Backup or retention failed; inspect owner logs and destination before retrying"
318
+ s.app.Logger().Error("hosting backup failed", "policy", id, "error", err)
319
+ }
320
+ run.Set("status", status)
321
+ run.Set("message", message)
322
+ run.Set("finished", time.Now())
323
+ saveErr := s.app.RunInTransaction(func(tx core.App) error {
324
+ if err := tx.Save(run); err != nil {
325
+ return err
326
+ }
327
+ current, err := tx.FindRecordById(PoliciesCollection, id)
328
+ if err != nil {
329
+ return err
330
+ }
331
+ current.Set("lastStatus", status)
332
+ return tx.Save(current)
333
+ })
334
+ if saveErr != nil {
335
+ return run, errors.New("backup outcome persistence failed; inspect destination")
336
+ }
337
+ if err != nil {
338
+ return run, errors.New(message)
339
+ }
340
+ return run, nil
341
+ }
342
+ func (s *Service) retain(ctx context.Context, target Target, prefix, name string, keep int, run *core.Record) error {
343
+ f, err := openTarget(s.app, target)
344
+ if err != nil {
345
+ return err
346
+ }
347
+ defer f.Close()
348
+ f.SetContext(ctx)
349
+ found := false
350
+ for _, key := range []string{name + ".age", name} {
351
+ found, err = f.Exists(key)
352
+ if err != nil {
353
+ return err
354
+ }
355
+ if found {
356
+ run.Set("key", key)
357
+ break
358
+ }
359
+ }
360
+ if !found {
361
+ return errors.New("snapshot completion could not be verified")
362
+ }
363
+ objects, err := f.List(prefix)
364
+ if err != nil {
365
+ return err
366
+ }
367
+ sort.Slice(objects, func(i, j int) bool { return objects[i].Key > objects[j].Key })
368
+ owned := 0
369
+ for _, o := range objects {
370
+ if o.IsDir || !strings.HasPrefix(o.Key, prefix) || filepath.Base(o.Key) != o.Key || !(strings.HasSuffix(o.Key, ".zip") || strings.HasSuffix(o.Key, ".zip.age")) {
371
+ continue
372
+ }
373
+ owned++
374
+ if owned > keep {
375
+ if err = f.Delete(o.Key); err != nil {
376
+ return fmt.Errorf("backup saved; retention incomplete: %w", err)
377
+ }
378
+ }
379
+ }
380
+ return nil
381
+ }
@@ -0,0 +1,81 @@
1
+ package hosting
2
+
3
+ import (
4
+ "context"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "github.com/pocketbase/pocketbase/tests"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ func TestDynamicBackupsRetainOnlyOwnedSnapshots(t *testing.T) {
14
+ app, err := tests.NewTestApp()
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ defer app.Cleanup()
19
+ dir := t.TempDir()
20
+ s, err := Register(app, Config{Targets: []Target{{Key: "local", Kind: "local", Path: dir}}})
21
+ if err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ c, _ := app.FindCollectionByNameOrId(PoliciesCollection)
25
+ p := core.NewRecord(c)
26
+ p.Set("name", "Hourly")
27
+ p.Set("target", "local")
28
+ p.Set("intervalMinutes", 60)
29
+ p.Set("keep", 1)
30
+ if err = app.Save(p); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ os.WriteFile(filepath.Join(dir, "unrelated.zip"), []byte("preserve"), 0600)
34
+ if run, err := s.run(context.Background(), p.Id, true); err != nil || run != nil {
35
+ t.Fatal("disabled scheduled policy ran", err)
36
+ }
37
+ first, err := s.Run(context.Background(), p.Id)
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ second, err := s.Run(context.Background(), p.Id)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if first.GetString("status") != "completed" || second.GetString("status") != "completed" {
46
+ t.Fatal("backup failed")
47
+ }
48
+ if _, err = os.Stat(filepath.Join(dir, first.GetString("key"))); !os.IsNotExist(err) {
49
+ t.Fatal("retention failed")
50
+ }
51
+ if _, err = os.Stat(filepath.Join(dir, second.GetString("key"))); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if _, err = os.Stat(filepath.Join(dir, "unrelated.zip")); err != nil {
55
+ t.Fatal("deleted unrelated backup")
56
+ }
57
+ policies, _ := app.FindCollectionByNameOrId(PoliciesCollection)
58
+ if policies.ListRule != nil || policies.CreateRule != nil {
59
+ t.Fatal("public policy management")
60
+ }
61
+ }
62
+ func TestBackupConfigurationRejectsUnsafeTargets(t *testing.T) {
63
+ for _, c := range []Config{{Targets: []Target{{Key: "local", Kind: "local", Path: "relative"}}}, {Targets: []Target{{Key: "s3", Kind: "s3", Endpoint: "http://storage", Bucket: "b", AccessKeyEnv: "ACCESS", SecretEnv: "SECRET"}}}, {Targets: []Target{{Key: "local", Kind: "native"}, {Key: "local", Kind: "native"}}}} {
64
+ if c.Validate() == nil {
65
+ t.Fatal("unsafe target accepted")
66
+ }
67
+ }
68
+ }
69
+ func TestRemoteBackupRejectsUnencryptedWrites(t *testing.T) {
70
+ t.Setenv("BACKUP_ACCESS", "test")
71
+ t.Setenv("BACKUP_SECRET", "test")
72
+ f, err := openTarget(nil, Target{Key: "remote", Kind: "s3", Endpoint: "https://storage.example.invalid", Bucket: "backups", Region: "us-east-1", AccessKeyEnv: "BACKUP_ACCESS", SecretEnv: "BACKUP_SECRET"})
73
+ if err != nil {
74
+ t.Fatal(err)
75
+ }
76
+ defer f.Close()
77
+ err = f.Upload([]byte("plaintext"), "hosting_test.zip")
78
+ if err == nil || !strings.Contains(err.Error(), "encrypted") {
79
+ t.Fatal("plaintext upload permitted", err)
80
+ }
81
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name":"commerce",
3
+ "pocketbaseImage":"registry.example.com/commerce-owner:1.0.0",
4
+ "vendureImage":"registry.example.com/commerce-vendure:1.0.0",
5
+ "gatewayImage":"registry.example.com/commerce-gateway:1.0.0",
6
+ "postgresImage":"postgres:18.6-alpine",
7
+ "tlsImage":"caddy:2.10.2-alpine",
8
+ "email":"operations@example.com",
9
+ "domains":[{"host":"team.example.com","store":"team-autumn"}],
10
+ "storageClass":"standard",
11
+ "ingressClass":"nginx",
12
+ "issuer":"letsencrypt-production",
13
+ "egressCIDRs":[]
14
+ }