@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,242 @@
1
+ package hosting
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "os"
9
+ "path/filepath"
10
+ "regexp"
11
+ "strings"
12
+ )
13
+
14
+ type Domain struct {
15
+ Host string `json:"host"`
16
+ Store string `json:"store"`
17
+ }
18
+ type Blueprint struct {
19
+ Name string `json:"name"`
20
+ PocketBaseImage string `json:"pocketbaseImage"`
21
+ VendureImage string `json:"vendureImage"`
22
+ GatewayImage string `json:"gatewayImage"`
23
+ PostgresImage string `json:"postgresImage"`
24
+ TLSImage string `json:"tlsImage"`
25
+ Email string `json:"email"`
26
+ Domains []Domain `json:"domains"`
27
+ StorageClass string `json:"storageClass"`
28
+ IngressClass string `json:"ingressClass"`
29
+ Issuer string `json:"issuer"`
30
+ EgressCIDRs []string `json:"egressCIDRs"`
31
+ }
32
+
33
+ func (b Blueprint) Validate() error {
34
+ if !dnsLabel(b.Name) {
35
+ return errors.New("DNS-label deployment name required")
36
+ }
37
+ for _, image := range []string{b.PocketBaseImage, b.VendureImage, b.GatewayImage, b.PostgresImage, b.TLSImage} {
38
+ if strings.ContainsAny(image, " \r\n\t\"'$") || !strings.Contains(image, ":") || strings.HasSuffix(image, ":latest") {
39
+ return errors.New("provide explicit versioned container images")
40
+ }
41
+ }
42
+ if b.Email == "" || strings.ContainsAny(b.Email, " \r\n{}") || len(b.Domains) < 1 || len(b.Domains) > 10000 {
43
+ return errors.New("email and verified domains required")
44
+ }
45
+ if !dnsHost(b.StorageClass) || !dnsHost(b.IngressClass) || !dnsHost(b.Issuer) {
46
+ return errors.New("storageClass, ingressClass and issuer names required")
47
+ }
48
+ seen := map[string]bool{}
49
+ for _, d := range b.Domains {
50
+ if !dnsHost(d.Host) || !strings.Contains(d.Host, ".") || net.ParseIP(d.Host) != nil || seen[d.Host] || !regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`).MatchString(d.Store) {
51
+ return errors.New("invalid domain/store route")
52
+ }
53
+ seen[d.Host] = true
54
+ }
55
+ for _, cidr := range b.EgressCIDRs {
56
+ if _, _, err := net.ParseCIDR(cidr); err != nil {
57
+ return err
58
+ }
59
+ }
60
+ return nil
61
+ }
62
+ func (b Blueprint) Render(dir string) error {
63
+ if err := b.Validate(); err != nil {
64
+ return err
65
+ }
66
+ if err := os.MkdirAll(dir, 0700); err != nil {
67
+ return err
68
+ }
69
+ write := func(name string, value any) error {
70
+ raw, err := json.MarshalIndent(value, "", " ")
71
+ if err != nil {
72
+ return err
73
+ }
74
+ return os.WriteFile(filepath.Join(dir, name), append(raw, '\n'), 0600)
75
+ }
76
+ routes := []map[string]any{}
77
+ for _, d := range b.Domains {
78
+ routes = append(routes, map[string]any{"host": d.Host, "store": d.Store, "origin": "http://pocketbase:8090", "publicPaths": []string{"/api/commerce/stores/" + d.Store, "/api/commerce/stores/" + d.Store + "/offers"}})
79
+ }
80
+ if err := write("gateway.json", map[string]any{"tenants": routes, "allowHTTP": true, "cacheSeconds": 2, "maxConcurrent": 128, "maxBodyBytes": 2097152}); err != nil {
81
+ return err
82
+ }
83
+ if err := write("compose.json", b.compose()); err != nil {
84
+ return err
85
+ }
86
+ if err := write("kubernetes.json", map[string]any{"apiVersion": "v1", "kind": "List", "items": b.kubernetes()}); err != nil {
87
+ return err
88
+ }
89
+ caddy := "{\n email " + b.Email + "\n}\n"
90
+ for _, d := range b.Domains {
91
+ caddy += d.Host + " {\n reverse_proxy gateway:8095\n}\n"
92
+ }
93
+ if err := os.WriteFile(filepath.Join(dir, "Caddyfile"), []byte(caddy), 0600); err != nil {
94
+ return err
95
+ }
96
+ return write("dns-plan.json", map[string]any{"domains": b.Domains, "instructions": "Only export verified PocketBase domain records. Configure TXT ownership proof and A/AAAA/CNAME to your ingress. TLS is provisioned by Caddy (Docker) or cert-manager (Kubernetes). Cloudflare/Route53 changes must use the actual zone and target supplied by your operator."})
97
+ }
98
+ func (b Blueprint) compose() map[string]any {
99
+ hard := func(image string, networks []string) map[string]any {
100
+ return map[string]any{"image": image, "restart": "unless-stopped", "read_only": true, "cap_drop": []string{"ALL"}, "security_opt": []string{"no-new-privileges:true"}, "networks": networks, "tmpfs": []string{"/tmp:rw,noexec,nosuid,size=256m"}, "mem_limit": "1g", "cpus": 2}
101
+ }
102
+ gw := hard(b.GatewayImage, []string{"edge", "application"})
103
+ gw["volumes"] = []string{"./gateway.json:/config/gateway.json:ro"}
104
+ gw["user"] = "65532:65532"
105
+ pb := hard(b.PocketBaseImage, []string{"application", "backup_egress"})
106
+ pb["command"] = []string{"serve", "--http=0.0.0.0:8090", "--dir=/data"}
107
+ pb["volumes"] = []string{"pocketbase_data:/data", "./commerce.json:/config/commerce.json:ro", "./hosting.json:/config/hosting.json:ro", "./pb_migrations:/app/pb_migrations:ro"}
108
+ pb["env_file"] = []string{"./owner.env"}
109
+ pb["environment"] = map[string]string{"VENDURE_ADDON_CONFIG": "/config/commerce.json", "HOSTING_ADDON_CONFIG": "/config/hosting.json"}
110
+ pb["ports"] = []string{"127.0.0.1:8090:8090"}
111
+ pb["stop_grace_period"] = "60s"
112
+ vendure := hard(b.VendureImage, []string{"application", "data", "service_egress"})
113
+ vendure["env_file"] = []string{"./vendure.env"}
114
+ vendure["depends_on"] = map[string]any{"postgres": map[string]string{"condition": "service_healthy"}}
115
+ postgres := map[string]any{"image": b.PostgresImage, "restart": "unless-stopped", "env_file": []string{"./postgres.env"}, "volumes": []string{"postgres_data:/var/lib/postgresql"}, "networks": []string{"data"}, "healthcheck": map[string]any{"test": []string{"CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"}, "interval": "10s", "timeout": "5s", "retries": 10}, "mem_limit": "2g", "cpus": 2}
116
+ tls := map[string]any{"image": b.TLSImage, "restart": "unless-stopped", "ports": []string{"80:80", "443:443", "443:443/udp"}, "volumes": []string{"./Caddyfile:/etc/caddy/Caddyfile:ro", "tls_data:/data", "tls_config:/config"}, "networks": []string{"edge"}, "security_opt": []string{"no-new-privileges:true"}, "mem_limit": "512m", "cpus": 1}
117
+ return map[string]any{"name": b.Name, "services": map[string]any{"tls": tls, "gateway": gw, "pocketbase": pb, "vendure": vendure, "postgres": postgres}, "networks": map[string]any{"edge": map[string]any{}, "application": map[string]bool{"internal": true}, "data": map[string]bool{"internal": true}, "backup_egress": map[string]any{}, "service_egress": map[string]any{}}, "volumes": map[string]any{"pocketbase_data": map[string]any{}, "postgres_data": map[string]any{}, "tls_data": map[string]any{}, "tls_config": map[string]any{}}}
118
+ }
119
+ func (b Blueprint) kubernetes() []any {
120
+ namespace := map[string]any{"apiVersion": "v1", "kind": "Namespace", "metadata": map[string]any{"name": b.Name, "labels": map[string]string{"pod-security.kubernetes.io/enforce": "restricted"}}}
121
+ resource := func(kind, name string, spec any) map[string]any {
122
+ return map[string]any{"apiVersion": "v1", "kind": kind, "metadata": map[string]any{"name": name, "namespace": b.Name}, "spec": spec}
123
+ }
124
+ items := []any{namespace}
125
+ for _, name := range []string{"pocketbase", "postgres"} {
126
+ pvc := resource("PersistentVolumeClaim", name+"-data", map[string]any{"accessModes": []string{"ReadWriteOncePod"}, "storageClassName": b.StorageClass, "resources": map[string]any{"requests": map[string]string{"storage": "20Gi"}}})
127
+ items = append(items, pvc)
128
+ }
129
+ ports := map[string]int{"gateway": 8095, "pocketbase": 8090, "vendure": 3000, "postgres": 5432}
130
+ images := map[string]string{"gateway": b.GatewayImage, "pocketbase": b.PocketBaseImage, "vendure": b.VendureImage, "postgres": b.PostgresImage}
131
+ for _, name := range []string{"gateway", "pocketbase", "vendure", "postgres"} {
132
+ replicas := 1
133
+ if name == "gateway" {
134
+ replicas = 2
135
+ }
136
+ container := map[string]any{"name": name, "image": images[name], "ports": []any{map[string]any{"containerPort": ports[name]}}, "resources": map[string]any{"requests": map[string]string{"cpu": "100m", "memory": "256Mi"}, "limits": map[string]string{"cpu": "2", "memory": "2Gi"}}, "securityContext": map[string]any{"allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true, "capabilities": map[string]any{"drop": []string{"ALL"}}}, "readinessProbe": map[string]any{"tcpSocket": map[string]int{"port": ports[name]}, "initialDelaySeconds": 10, "periodSeconds": 10}, "livenessProbe": map[string]any{"tcpSocket": map[string]int{"port": ports[name]}, "initialDelaySeconds": 60, "periodSeconds": 20}, "volumeMounts": []any{map[string]string{"name": "tmp", "mountPath": "/tmp"}}}
137
+ volumes := []any{map[string]any{"name": "tmp", "emptyDir": map[string]string{"sizeLimit": "256Mi"}}}
138
+ mounts := container["volumeMounts"].([]any)
139
+ if name == "pocketbase" || name == "postgres" {
140
+ mount := "/data"
141
+ if name == "postgres" {
142
+ mount = "/var/lib/postgresql"
143
+ }
144
+ mounts = append(mounts, map[string]string{"name": "data", "mountPath": mount})
145
+ volumes = append(volumes, map[string]any{"name": "data", "persistentVolumeClaim": map[string]string{"claimName": name + "-data"}})
146
+ }
147
+ if name == "gateway" || name == "pocketbase" {
148
+ mounts = append(mounts, map[string]any{"name": "config", "mountPath": "/config", "readOnly": true})
149
+ volumes = append(volumes, map[string]any{"name": "config", "configMap": map[string]string{"name": "commerce-config"}})
150
+ }
151
+ if name != "gateway" {
152
+ container["envFrom"] = []any{map[string]any{"secretRef": map[string]string{"name": name + "-env"}}}
153
+ }
154
+ if name == "pocketbase" {
155
+ container["args"] = []string{"serve", "--http=0.0.0.0:8090", "--dir=/data"}
156
+ container["env"] = []any{map[string]string{"name": "VENDURE_ADDON_CONFIG", "value": "/config/commerce.json"}, map[string]string{"name": "HOSTING_ADDON_CONFIG", "value": "/config/hosting.json"}}
157
+ }
158
+ if name == "postgres" {
159
+ mounts = append(mounts, map[string]string{"name": "postgres-run", "mountPath": "/var/run/postgresql"})
160
+ volumes = append(volumes, map[string]any{"name": "postgres-run", "emptyDir": map[string]string{"sizeLimit": "16Mi"}})
161
+ }
162
+ container["volumeMounts"] = mounts
163
+ pod := map[string]any{"automountServiceAccountToken": false, "securityContext": map[string]any{"runAsNonRoot": true, "runAsUser": 65532, "runAsGroup": 65532, "fsGroup": 65532, "seccompProfile": map[string]string{"type": "RuntimeDefault"}}, "containers": []any{container}, "volumes": volumes, "terminationGracePeriodSeconds": 60}
164
+ if name == "postgres" {
165
+ pod["securityContext"].(map[string]any)["runAsUser"] = 70
166
+ pod["securityContext"].(map[string]any)["runAsGroup"] = 70
167
+ pod["securityContext"].(map[string]any)["fsGroup"] = 70
168
+ }
169
+ d := resource("Deployment", name, map[string]any{"replicas": replicas, "strategy": map[string]string{"type": "Recreate"}, "selector": map[string]any{"matchLabels": map[string]string{"app": name}}, "template": map[string]any{"metadata": map[string]any{"labels": map[string]string{"app": name}}, "spec": pod}})
170
+ if name == "gateway" {
171
+ d["spec"].(map[string]any)["strategy"] = map[string]any{"type": "RollingUpdate", "rollingUpdate": map[string]int{"maxUnavailable": 1, "maxSurge": 1}}
172
+ }
173
+ if name == "gateway" || name == "pocketbase" {
174
+ path := "/healthz"
175
+ if name == "pocketbase" {
176
+ path = "/api/health"
177
+ }
178
+ container["readinessProbe"] = map[string]any{"httpGet": map[string]any{"path": path, "port": ports[name]}, "initialDelaySeconds": 5, "periodSeconds": 10}
179
+ }
180
+ d["apiVersion"] = "apps/v1"
181
+ items = append(items, d, resource("Service", name, map[string]any{"selector": map[string]string{"app": name}, "ports": []any{map[string]int{"port": ports[name], "targetPort": ports[name]}}}))
182
+ }
183
+ policy := func(name string, spec any) {
184
+ v := resource("NetworkPolicy", name, spec)
185
+ v["apiVersion"] = "networking.k8s.io/v1"
186
+ items = append(items, v)
187
+ }
188
+ policy("default-deny", map[string]any{"podSelector": map[string]any{}, "policyTypes": []string{"Ingress", "Egress"}})
189
+ // Both ends of each application connection are explicitly allowed.
190
+ for _, edge := range []struct {
191
+ from, to string
192
+ port int
193
+ }{{"gateway", "pocketbase", 8090}, {"pocketbase", "vendure", 3000}, {"vendure", "postgres", 5432}} {
194
+ for _, direction := range []string{"ingress", "egress"} {
195
+ name, peer := edge.to, edge.from
196
+ selector := "from"
197
+ if direction == "egress" {
198
+ name, peer = edge.from, edge.to
199
+ selector = "to"
200
+ }
201
+ policy(name+"-"+direction+"-"+peer, map[string]any{"podSelector": map[string]any{"matchLabels": map[string]string{"app": name}}, direction: []any{map[string]any{selector: []any{map[string]any{"podSelector": map[string]any{"matchLabels": map[string]string{"app": peer}}}}, "ports": []any{map[string]any{"protocol": "TCP", "port": edge.port}}}}})
202
+ }
203
+ }
204
+ policy("cluster-dns", map[string]any{"podSelector": map[string]any{}, "egress": []any{map[string]any{"to": []any{map[string]any{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"kubernetes.io/metadata.name": "kube-system"}}}}, "ports": []any{map[string]any{"protocol": "UDP", "port": 53}, map[string]any{"protocol": "TCP", "port": 53}}}}})
205
+ policy("ingress-to-gateway", map[string]any{"podSelector": map[string]any{"matchLabels": map[string]string{"app": "gateway"}}, "ingress": []any{map[string]any{"from": []any{map[string]any{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"x47-ingress": "true"}}}}, "ports": []any{map[string]any{"protocol": "TCP", "port": 8095}}}}})
206
+ if len(b.EgressCIDRs) > 0 {
207
+ destinations := []any{}
208
+ for _, cidr := range b.EgressCIDRs {
209
+ destinations = append(destinations, map[string]any{"ipBlock": map[string]string{"cidr": cidr}})
210
+ }
211
+ policy("configured-https-egress", map[string]any{"podSelector": map[string]any{"matchExpressions": []any{map[string]any{"key": "app", "operator": "In", "values": []string{"pocketbase", "vendure"}}}}, "egress": []any{map[string]any{"to": destinations, "ports": []any{map[string]any{"protocol": "TCP", "port": 443}}}}})
212
+ }
213
+ pdb := resource("PodDisruptionBudget", "gateway", map[string]any{"minAvailable": 1, "selector": map[string]any{"matchLabels": map[string]string{"app": "gateway"}}})
214
+ pdb["apiVersion"] = "policy/v1"
215
+ items = append(items, pdb)
216
+ rules := []any{}
217
+ tls := []any{}
218
+ for i, d := range b.Domains {
219
+ rules = append(rules, map[string]any{"host": d.Host, "http": map[string]any{"paths": []any{map[string]any{"path": "/", "pathType": "Prefix", "backend": map[string]any{"service": map[string]any{"name": "gateway", "port": map[string]int{"number": 8095}}}}}}})
220
+ tls = append(tls, map[string]any{"hosts": []string{d.Host}, "secretName": fmt.Sprintf("store-tls-%d", i)})
221
+ }
222
+ ingress := resource("Ingress", "stores", map[string]any{"ingressClassName": b.IngressClass, "rules": rules, "tls": tls})
223
+ ingress["apiVersion"] = "networking.k8s.io/v1"
224
+ ingress["metadata"].(map[string]any)["annotations"] = map[string]string{"cert-manager.io/cluster-issuer": b.Issuer}
225
+ items = append(items, ingress)
226
+ return items
227
+ }
228
+
229
+ func dnsLabel(s string) bool {
230
+ return len(s) <= 63 && regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`).MatchString(s)
231
+ }
232
+ func dnsHost(s string) bool {
233
+ if s == "" || len(s) > 253 {
234
+ return false
235
+ }
236
+ for _, part := range strings.Split(s, ".") {
237
+ if !dnsLabel(part) {
238
+ return false
239
+ }
240
+ }
241
+ return true
242
+ }
@@ -0,0 +1,98 @@
1
+ package hosting
2
+
3
+ import (
4
+ "encoding/json"
5
+ "github.com/spink-dev/pocketbase-extension/multinode"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func testBlueprint() Blueprint {
13
+ return Blueprint{Name: "commerce", PocketBaseImage: "example/pocketbase:1.0.0", VendureImage: "example/vendure:1.0.0", GatewayImage: "example/gateway:1.0.0", PostgresImage: "postgres:18.6-alpine", TLSImage: "caddy:2.10.2-alpine", Email: "admin@example.com", Domains: []Domain{{Host: "team.example.com", Store: "team"}}, StorageClass: "standard", IngressClass: "nginx", Issuer: "letsencrypt", EgressCIDRs: []string{"203.0.113.0/24"}}
14
+ }
15
+ func TestRenderPrivateOriginsAndSingleWriter(t *testing.T) {
16
+ b := testBlueprint()
17
+ dir := t.TempDir()
18
+ if err := b.Render(dir); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ var compose map[string]any
22
+ raw, _ := os.ReadFile(filepath.Join(dir, "compose.json"))
23
+ json.Unmarshal(raw, &compose)
24
+ services := compose["services"].(map[string]any)
25
+ if _, ok := services["postgres"].(map[string]any)["ports"]; ok {
26
+ t.Fatal("public database")
27
+ }
28
+ if _, ok := services["vendure"].(map[string]any)["ports"]; ok {
29
+ t.Fatal("public Vendure origin")
30
+ }
31
+ pb := services["pocketbase"].(map[string]any)
32
+ if pb["ports"].([]any)[0] != "127.0.0.1:8090:8090" {
33
+ t.Fatal("public management")
34
+ }
35
+ var kube struct{ Items []map[string]any }
36
+ raw, _ = os.ReadFile(filepath.Join(dir, "kubernetes.json"))
37
+ json.Unmarshal(raw, &kube)
38
+ deny, pdb := false, false
39
+ for _, v := range kube.Items {
40
+ meta := v["metadata"].(map[string]any)
41
+ if v["kind"] == "NetworkPolicy" && meta["name"] == "default-deny" {
42
+ deny = true
43
+ }
44
+ if v["kind"] == "PodDisruptionBudget" {
45
+ pdb = true
46
+ }
47
+ if v["kind"] == "Deployment" && meta["name"] == "pocketbase" {
48
+ spec := v["spec"].(map[string]any)
49
+ if spec["replicas"] != float64(1) || spec["strategy"].(map[string]any)["type"] != "Recreate" {
50
+ t.Fatal("unsafe SQLite deployment")
51
+ }
52
+ }
53
+ }
54
+ if !deny || !pdb {
55
+ t.Fatal("missing networking or disruption protection")
56
+ }
57
+ }
58
+ func TestBlueprintRejectsConfigInjection(t *testing.T) {
59
+ for _, host := range []string{"*.example.com", "example.com\n}", "example.com/path", "user@example.com"} {
60
+ b := testBlueprint()
61
+ b.Domains[0].Host = host
62
+ if b.Validate() == nil {
63
+ t.Fatal(host)
64
+ }
65
+ }
66
+ b := testBlueprint()
67
+ b.PocketBaseImage = "example:latest"
68
+ if b.Validate() == nil {
69
+ t.Fatal("mutable image accepted")
70
+ }
71
+ }
72
+
73
+ func TestRenderedRoutesMatchGatewayContract(t *testing.T) {
74
+ b := testBlueprint()
75
+ b.Domains[0].Store = strings.Repeat("a", 64)
76
+ dir := t.TempDir()
77
+ if err := b.Render(dir); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ f, err := os.Open(filepath.Join(dir, "gateway.json"))
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ defer f.Close()
85
+ c, err := multinode.Load(f)
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ g, err := multinode.New(c)
90
+ if err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ defer g.CloseIdleConnections()
94
+ b.Domains[0].Store += "a"
95
+ if b.Validate() == nil {
96
+ t.Fatal("renderer accepted store rejected by gateway")
97
+ }
98
+ }
@@ -0,0 +1,126 @@
1
+ package hosting
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "errors"
7
+ "github.com/pocketbase/pocketbase/core"
8
+ "io"
9
+ "net/url"
10
+ "os"
11
+ "path/filepath"
12
+ "regexp"
13
+ "strings"
14
+ )
15
+
16
+ type Target struct {
17
+ Key string `json:"key"`
18
+ Kind string `json:"kind"`
19
+ Path string `json:"path,omitempty"`
20
+ Bucket string `json:"bucket,omitempty"`
21
+ Region string `json:"region,omitempty"`
22
+ Endpoint string `json:"endpoint,omitempty"`
23
+ AccessKeyEnv string `json:"accessKeyEnv,omitempty"`
24
+ SecretEnv string `json:"secretEnv,omitempty"`
25
+ PathStyle bool `json:"pathStyle,omitempty"`
26
+ }
27
+ type Config struct {
28
+ Targets []Target `json:"targets"`
29
+ }
30
+
31
+ var keyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
32
+ var envPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
33
+
34
+ func (c Config) Validate() error {
35
+ seen := map[string]bool{}
36
+ if len(c.Targets) < 1 || len(c.Targets) > 32 {
37
+ return errors.New("configure 1-32 backup targets")
38
+ }
39
+ for _, t := range c.Targets {
40
+ if !keyPattern.MatchString(t.Key) || seen[t.Key] {
41
+ return errors.New("invalid or duplicate target key")
42
+ }
43
+ seen[t.Key] = true
44
+ switch t.Kind {
45
+ case "native":
46
+ case "local":
47
+ if !filepath.IsAbs(t.Path) {
48
+ return errors.New("local backup target must be absolute")
49
+ }
50
+ case "s3":
51
+ u, err := url.Parse(t.Endpoint)
52
+ if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || t.Bucket == "" || t.Region == "" || !envPattern.MatchString(t.AccessKeyEnv) || !envPattern.MatchString(t.SecretEnv) {
53
+ return errors.New("S3 requires fixed HTTPS endpoint, bucket and credential environment references")
54
+ }
55
+ default:
56
+ return errors.New("unknown backup target kind")
57
+ }
58
+ }
59
+ return nil
60
+ }
61
+ func Load(path string) (Config, error) {
62
+ var c Config
63
+ f, err := os.Open(path)
64
+ if err != nil {
65
+ return c, err
66
+ }
67
+ defer f.Close()
68
+ raw, err := io.ReadAll(io.LimitReader(f, 65537))
69
+ if err != nil || len(raw) > 65536 {
70
+ return c, errors.New("invalid hosting configuration")
71
+ }
72
+ decoder := json.NewDecoder(bytes.NewReader(raw))
73
+ decoder.DisallowUnknownFields()
74
+ if err = decoder.Decode(&c); err != nil {
75
+ return c, err
76
+ }
77
+ if decoder.Decode(new(any)) != io.EOF {
78
+ return c, errors.New("unexpected trailing configuration")
79
+ }
80
+ return c, c.Validate()
81
+ }
82
+
83
+ // Resolve existing parents too, so a not-yet-created directory cannot hide a symlink.
84
+ func resolvedPath(path string) (string, error) {
85
+ path, err := filepath.Abs(path)
86
+ if err != nil {
87
+ return "", err
88
+ }
89
+ resolved, err := filepath.EvalSymlinks(path)
90
+ if err == nil {
91
+ return resolved, nil
92
+ }
93
+ if !os.IsNotExist(err) {
94
+ return "", err
95
+ }
96
+ parent := filepath.Dir(path)
97
+ if parent == path {
98
+ return "", err
99
+ }
100
+ resolved, err = resolvedPath(parent)
101
+ if err != nil {
102
+ return "", err
103
+ }
104
+ return filepath.Join(resolved, filepath.Base(path)), nil
105
+ }
106
+ func validateLocalTarget(dataDir, target string) error {
107
+ data, err := resolvedPath(dataDir)
108
+ if err != nil {
109
+ return err
110
+ }
111
+ dest, err := resolvedPath(target)
112
+ if err != nil {
113
+ return err
114
+ }
115
+ rel, err := filepath.Rel(data, dest)
116
+ if err != nil {
117
+ return err
118
+ }
119
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
120
+ return nil
121
+ }
122
+ if rel == core.LocalBackupsDirName || strings.HasPrefix(rel, core.LocalBackupsDirName+string(os.PathSeparator)) {
123
+ return nil
124
+ }
125
+ return errors.New("local backup target must be outside pb_data or inside its excluded backups directory")
126
+ }
@@ -0,0 +1 @@
1
+ {"provider":"cloudflare","zoneID":"00000000000000000000000000000000","tokenEnv":"CLOUDFLARE_DNS_TOKEN","records":[{"name":"_x47-store.team.example.com","type":"TXT","content":"x47=REPLACE_WITH_POCKETBASE_CHALLENGE","ttl":300},{"name":"team.example.com","type":"CNAME","content":"ingress.example.com","ttl":300}]}
@@ -0,0 +1,98 @@
1
+ # Docker and Kubernetes deployment
2
+
3
+ ## Application image contract
4
+
5
+ Build versioned images before rendering. Use the standard PocketBase Go host plus the base, commerce and hosting registrations; include your own storefront endpoints, assets, business hooks and schema migrations. Do not deploy the local SQL.js/dummy-payment commerce fixture as a production Vendure image.
6
+
7
+ | Image | Required behavior |
8
+ |---|---|
9
+ | PocketBase owner | Entrypoint is the Go binary; accepts `serve --http=0.0.0.0:8090 --dir=/data`. Register all three add-ons. Read `VENDURE_ADDON_CONFIG` and `HOSTING_ADDON_CONFIG`. Bind the custom-domain guard, keep management on the private origin, ship the application's storefront assets. Run as non-root with writable `/data` and `/tmp`. |
10
+ | Vendure | Production Vendure server on port 3000, `shop-api` and `admin-api`; PostgreSQL configuration from environment. Register the commerce campaign and store-scope plugins, use the same store signing secret as the owner. Ship the dashboard and assets, run migrations deliberately before release. Run workers/queues for production jobs according to your Vendure application. |
11
+ | Gateway | Combined package Dockerfile built with `--target gateway`; listens on 8095, reads `/config/gateway.json`, runs as UID 65532. |
12
+ | PostgreSQL | Example targets the official PostgreSQL 18 Alpine layout, UID/GID 70 for Kubernetes, data below `/var/lib/postgresql`. Adapt security context and paths if replacing it with another image. |
13
+
14
+ Use the base add-on's Go/JavaScript migration integration in your owner. Docker mounts `./pb_migrations` at `/app/pb_migrations`; the owner must explicitly register that directory. For Kubernetes, bake immutable migrations into the versioned image and configure their path there. Schema generation/synchronization is not automatically enabled. Back up before schema changes; do not auto-retry failed payment/order mutations.
15
+
16
+ ## Docker
17
+
18
+ In `generated/`, provide:
19
+
20
+ - `commerce.json`: real fixed Vendure Admin API URL `http://vendure:3000/admin-api`, configured channel aliases and credential environment references. Explicitly allow private HTTP inside this deployment. Set `domainTarget` to your ingress hostname. Never accept upstream URLs from the browser.
21
+ - `hosting.json`: named backup targets, typically local `/data/backups/archives` plus encrypted S3.
22
+ - `owner.env`: configured Vendure Admin credentials, store signing key and base backup encryption key. Do not place secrets directly in the blueprint.
23
+ - `vendure.env`: database connection to `postgres:5432`, matching store secret and production application settings.
24
+ - `postgres.env`: `POSTGRES_DB`, `POSTGRES_USER`, strong `POSTGRES_PASSWORD`; values must match Vendure's database configuration.
25
+ - `pb_migrations/`: your versioned migrations.
26
+
27
+ Environment key names other than the two add-on config paths and official PostgreSQL keys are your image's contract. Configure the same Vendure store-scope secret in both services. Register backup encryption through the base add-on's settings rather than merely setting a key and assuming encryption is enabled.
28
+
29
+ Rendered files are private by default. Non-root containers need read permission on bind-mounted **nonsecret** gateway/commerce/hosting config files and Caddyfile. Set ownership for the container user or use `chmod 644 gateway.json commerce.json hosting.json Caddyfile` when they contain only public configuration/environment references. Keep `*.env` at mode 600. Ensure your image initializes named volume ownership for its runtime user; never make the database publicly writable.
30
+
31
+ ```sh
32
+ cd generated
33
+ docker compose -f compose.json config --quiet
34
+ docker compose -f compose.json up -d
35
+ ```
36
+
37
+ Only Caddy exposes public 80/443; management binds `127.0.0.1:8090`. PostgreSQL and Vendure publish no host ports. Use SSH tunneling for remote PocketBase management. For temporary Vendure dashboard access, run an explicitly loopback-bound service override or a private authenticated administration ingress; never put the generic Admin API behind a public shop hostname.
38
+
39
+ Docker networks separate ingress, application, data, backup egress and Vendure service egress. Internal networks have no direct external routing. The two egress networks allow outbound access; they are **not destination allowlists**. Use host firewall rules or an egress proxy for destination restrictions. TLS termination and gateway body/concurrency limits reduce origin work but do not eliminate volumetric attacks against the network uplink.
40
+
41
+ On route changes, bind-mounted files require care: atomic host file replacement may leave a running container on the old bind-mount inode. Recreate the gateway service after replacing `gateway.json`, or mount the config directory and send SIGHUP after validation. Native SIGHUP reload keeps the prior configuration if the replacement is invalid. Recreate/reload Caddy after changing its hostname list.
42
+
43
+ ## Kubernetes
44
+
45
+ Use an existing cluster with an enforcing CNI, CSI storage supporting `ReadWriteOncePod`, an ingress controller and a configured cert-manager **DNS-01 ClusterIssuer**. The default-deny policy does not permit arbitrary HTTP-01 solver pods. The storage class and controller names in the blueprint must exist. Namespace labels alone do not install those components.
46
+
47
+ Separate namespaces/projects should use separate rendered blueprints and secrets. Apply to a test namespace first. These example commands change cluster resources; run them only against the intended context:
48
+
49
+ ```sh
50
+ kubectl config current-context
51
+ kubectl apply --dry-run=client --validate=strict -f generated/kubernetes.json
52
+ kubectl create namespace commerce --dry-run=client -o yaml | kubectl apply -f -
53
+ kubectl -n commerce create secret generic pocketbase-env --from-env-file=generated/owner.env
54
+ kubectl -n commerce create secret generic vendure-env --from-env-file=generated/vendure.env
55
+ kubectl -n commerce create secret generic postgres-env --from-env-file=generated/postgres.env
56
+ kubectl -n commerce create configmap commerce-config \
57
+ --from-file=generated/gateway.json --from-file=generated/commerce.json --from-file=generated/hosting.json
58
+ # Label your actual ingress controller namespace, after reviewing its isolation.
59
+ kubectl label namespace YOUR_INGRESS_NAMESPACE x47-ingress=true
60
+ kubectl apply -f generated/kubernetes.json
61
+ kubectl -n commerce rollout status deployment/gateway
62
+ kubectl -n commerce rollout status deployment/pocketbase
63
+ kubectl -n commerce port-forward service/pocketbase 8090:8090
64
+ # In another terminal, for private Vendure administration:
65
+ kubectl -n commerce port-forward service/vendure 19400:3000
66
+ ```
67
+
68
+ Replace `commerce` with the blueprint's namespace. Update existing secrets/configmaps through your secret management/GitOps process, without committing secret values. Mounted configmap updates do not reload a running gateway by themselves: after the new mounted content is visible, roll out the gateway. Roll out the owner for changes to its static channel/target configuration. Never roll out more than one owner writer.
69
+
70
+ The gateway uses two replicas and a disruption budget. PocketBase and PostgreSQL use one replica with Recreate and single-pod persistent access. This prevents intentionally scheduled concurrent writers, not arbitrary storage corruption or all split-brain scenarios. Planned owner/PostgreSQL restarts interrupt their availability. Add partitioning or separately designed database failover when required; do not simply increase replicas.
71
+
72
+ ```mermaid
73
+ flowchart LR
74
+ clients[Shop clients] --> ingress[TLS ingress]
75
+ ingress --> gateway[Store-bound gateways]
76
+ gateway --> owner[One PocketBase writer per partition]
77
+ owner --> vendure[Vendure commerce service]
78
+ vendure --> postgres[PostgreSQL]
79
+ owner --> backups[Configured backup target]
80
+ operators[Private administration] --> owner
81
+ operators --> vendure
82
+ ```
83
+
84
+ NetworkPolicy permits gateway→owner:8090, owner→Vendure:3000 and Vendure→PostgreSQL:5432, plus cluster DNS. Public ingress is permitted only from namespaces labeled `x47-ingress=true` to gateway:8095. Egress CIDRs allow owner/Vendure HTTPS to configured remote storage/payment services. An empty CIDR list disables that external HTTPS access. CIDRs do not follow changing DNS answers: maintain provider ranges or use an egress gateway/CNI FQDN policy. Node-local DNS and controller-specific health checks may require cluster-specific policies. There is no automatically public Vendure webhook endpoint; add a narrowly scoped, authenticated/verified application route for your payment provider.
85
+
86
+ ## Backup and restore operations
87
+
88
+ 1. In native PocketBase **Hosting**, create a paused policy and run a snapshot. Check its completed record and destination object. Enable the schedule only after verifying a recovery copy.
89
+ 2. Keep encryption keys separately from snapshots. A key on the same failed owner is not a recovery plan.
90
+ 3. Restore a copy in a disposable owner using the matching application/migrations. For alternate targets, securely retrieve the snapshot and upload/import it through the base add-on's supported backup recovery flow; the native PocketBase backup browser lists only its configured default target.
91
+ 4. Verify records, file attachments, store memberships, policies and authentication after restoration. Review restored schedules before reconnecting remote destinations to avoid unexpected retention deletions. Rotate copied credentials in a recovery environment.
92
+ 5. Back up PostgreSQL independently with your chosen PostgreSQL backup/WAL tooling and test its recovery. A PocketBase snapshot is not a cross-database atomic commerce snapshot. Reconcile application state across recovery points.
93
+
94
+ ## Provider interfaces and verification boundary
95
+
96
+ DNS plans support explicit Cloudflare API creation and Route 53 `CREATE` batches. Cloudflare requires zone-scoped DNS Read/Edit; AWS should limit `route53:ChangeResourceRecordSets` to the intended hosted zone/record names. Provider apply was not exercised against a real account in this implementation. S3 plaintext rejection, local snapshot retention, application authorization, browser flows and generated manifest validation were exercised; actual S3 delivery, public ACME, cluster admission/scheduling and restore drills still require deployment-specific verification.
97
+
98
+ Primary references: [Kubernetes network policies](https://kubernetes.io/docs/concepts/services-networking/network-policies/), [single-pod volume access](https://kubernetes.io/docs/tasks/administer-cluster/change-pv-access-mode-readwriteoncepod/), [official PostgreSQL image](https://hub.docker.com/_/postgres), [Cloudflare DNS create](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/), [AWS Route 53 CLI](https://docs.aws.amazon.com/cli/latest/reference/route53/change-resource-record-sets.html), [Vercel Go request handlers](https://vercel.com/docs/functions/runtimes/go).
@@ -0,0 +1 @@
1
+ {"targets":[{"key":"owner-default","kind":"native"},{"key":"local-archive","kind":"local","path":"/data/backups/archives"},{"key":"remote-s3","kind":"s3","bucket":"replace-me","region":"auto","endpoint":"https://account-id.r2.cloudflarestorage.com","accessKeyEnv":"BACKUP_ACCESS_KEY","secretEnv":"BACKUP_SECRET_KEY","pathStyle":false}]}
@@ -0,0 +1,29 @@
1
+ package hosting
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func TestLocalTargetCannotIncludeBackupsInNextSnapshot(t *testing.T) {
10
+ root := t.TempDir()
11
+ data := filepath.Join(root, "pb_data")
12
+ if err := os.MkdirAll(filepath.Join(data, "backups"), 0700); err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ alias := filepath.Join(root, "alias")
16
+ if err := os.Symlink(data, alias); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ for _, path := range []string{data, filepath.Join(data, "archives"), filepath.Join(alias, "archives", "new")} {
20
+ if err := validateLocalTarget(data, path); err == nil {
21
+ t.Errorf("accepted recursive target %s", path)
22
+ }
23
+ }
24
+ for _, path := range []string{filepath.Join(root, "outside"), filepath.Join(data, "backups", "archives")} {
25
+ if err := validateLocalTarget(data, path); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ }
29
+ }