@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
package/Dockerfile CHANGED
@@ -4,20 +4,28 @@ COPY go.mod go.sum ./
4
4
  RUN go mod download
5
5
  COPY . .
6
6
  RUN CGO_ENABLED=0 go build -trimpath -o /pocketbase ./cmd/pocketbase && \
7
- CGO_ENABLED=0 go build -trimpath -o /edge ./cmd/edge
7
+ CGO_ENABLED=0 go build -trimpath -o /edge ./cmd/edge && \
8
+ CGO_ENABLED=0 go build -trimpath -o /gateway ./cmd/gateway
8
9
  RUN mkdir -p /data/security && chown -R 65532:65532 /data
9
10
 
10
11
  FROM gcr.io/distroless/static-debian12:nonroot AS edge
11
12
  COPY --from=build /edge /edge
13
+ COPY --from=build --chown=65532:65532 /data/security /telemetry
12
14
  EXPOSE 8080 8081
13
15
  HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD ["/edge", "-probe", "http://127.0.0.1:8081/edge/readyz"]
14
16
  ENTRYPOINT ["/edge"]
15
17
 
18
+ FROM gcr.io/distroless/static-debian12:nonroot AS gateway
19
+ COPY --from=build /gateway /gateway
20
+ EXPOSE 8095
21
+ ENTRYPOINT ["/gateway","-listen",":8095","-config","/config/gateway.json"]
22
+
16
23
  FROM gcr.io/distroless/static-debian12:nonroot AS pocketbase
17
24
  COPY --from=build /pocketbase /pocketbase
18
25
  COPY --from=build /edge /edge
19
26
  COPY --from=build --chown=65532:65532 /data /pb_data
20
27
  COPY --from=build --chown=65532:65532 /data/security /state
28
+ COPY --from=build --chown=65532:65532 /data/security /telemetry
21
29
  COPY --chown=65532:65532 deploy/app /app
22
30
  EXPOSE 8090
23
31
  VOLUME ["/pb_data"]
package/FEATURES.md CHANGED
@@ -30,3 +30,20 @@ The earlier decisions to defer native WebAuthn (#6800) and a new nested storage
30
30
  The experimental native UI integration and JSVM/watcher compatibility seams are version-pinned. Future PocketBase upgrades require this repository's checks and browser/restore verification; separation makes upgrades manageable, not automatically safe.
31
31
 
32
32
  The Docker edge add-on (`edge/`, `cmd/edge`, `deploy/`) now rejects public traffic before upstream work: explicit collections, CIDR/path blocks, synchronized enforce-mode family actions, bounded budgets/clients/concurrency/bodies, and separate operator capacity. Policy synchronization is asynchronous; existing requests are not recalled. Custom migrations/hooks/assets ship in the image. Gateway counters are local at `/edge/status`. See deployment docs for route and availability limits.
33
+
34
+ ## Firewall visibility and upload hardening
35
+
36
+ Native Security now separates public firewall metrics from application traffic,
37
+ shows rejection reasons, body bytes versus declared sizes, one-second traffic
38
+ buckets, upstream health and effective upload/concurrency limits. Every edge denial
39
+ is retained immediately as a grouped policy event. Imported events use the existing
40
+ incident review and acknowledgement workflow. Missing/stale telemetry is explicit.
41
+ Public uploads have bounded buffers, a five-second default body deadline and a
42
+ four-request default client concurrency limit. No change grants guaranteed uptime
43
+ or classifies every rejected request as a malicious attack.
44
+
45
+ ## Unified optional features
46
+
47
+ - `hosting/`: named backup targets, superuser backup policies and native Hosting dashboard; enabled by `HOSTING_ADDON_CONFIG` in the CLI. Includes commerce deployment rendering and DNS/route tools.
48
+ - `multinode/`, `cmd/gateway`: explicit host-to-owner routing with opt-in bounded public response caching. Runs independently from the database server.
49
+ - `pocketbase-addon init`: generates missing development configuration without overwriting operator files. All sources and guides ship in the npm tarball.
package/MIGRATION.md CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  ## Go application
4
4
 
5
- Use `require github.com/spink-dev/pocketbase-extension v0.0.0` and a **local** replace to `/Users/x47base/git/spink-dev_pocketbase-extension` until a release is published. Do not replace `github.com/pocketbase/pocketbase`.
5
+ Install `@x47base/pocketbase-addon` with npm (or install its local `.tgz` before publication). In your Go host, use:
6
+
7
+ ```sh
8
+ go mod edit -require=github.com/spink-dev/pocketbase-extension@v0.0.0
9
+ go mod edit -replace=github.com/spink-dev/pocketbase-extension=./node_modules/@x47base/pocketbase-addon
10
+ go mod tidy
11
+ ```
12
+
13
+ Do not replace `github.com/pocketbase/pocketbase`. Node is only needed for package installation; production may run the compiled Go binary.
6
14
 
7
15
  ```go
8
16
  import (
@@ -28,7 +36,7 @@ Register before bootstrap, so field factories, first-run settings and watcher ho
28
36
  3. Import copies native `backups.encrypted/encryptionEnv` and per-template `locales` into extension-owned `_params.spink_extension_v1`. It preserves existing extension settings and is idempotent. Missing backup keys fail validation instead of disabling encryption. JSON `schema` is preserved by the registered field wrapper.
29
37
  4. Start the copied database using this package. Review Security, x47base extensions, field schemas, mail templates and a disposable backup/restore drill. Only then switch the live `--dir` during downtime.
30
38
 
31
- The current development database stays at `/Users/x47base/git/pocketbase/adapter/pb_data`; no account reset or data move is needed. The older deleted temporary database cannot be recovered by package extraction.
39
+ Keep the existing database at its explicit `--dir`; package installation never moves or resets it.
32
40
 
33
41
  ## Backups
34
42
 
@@ -47,3 +55,44 @@ Do not run a plain stock executable against custom field metadata. Roll back wit
47
55
  ## Docker edge deployment
48
56
 
49
57
  New opt-in Compose deployment uses fresh named volumes and ports 8080/8081. It does not attach or migrate the existing host database. CLI adds `PB_SECURITY_STATE_PATH` (absolute), `PB_SECURITY_TRUSTED_PEERS`, `PB_SECURITY_MANAGEMENT_PEERS`, `PB_SECURITY_OPERATOR_PEERS` (comma-separated CIDRs; operator peers are runtime-only), and `PB_BACKUP_ENCRYPTION_KEY_FILE`. Existing persisted security policy retains precedence for peer lists; verify those after moving a database. Compose stores checkpoint state outside `pb_data` so native/encrypted restores can replace database files without moving a mounted state directory. Preserve and recover this volume separately. See deploy/README.md for deliberate public-route restrictions and migration packaging.
58
+
59
+ ## Security observability update
60
+
61
+ - Deploy the new PocketBase and edge binaries together. For Docker, use the updated
62
+ Compose template's separate telemetry volume and flags; see deploy/README.md.
63
+ `Config.EdgeStatePath` is runtime-only and survives policy edits/reloads.
64
+ - `/api/security/status` adds `edge`, `edgeConfigured`, and `edgeStale`.
65
+ `/edge/status` preserves `accepted` (alias of `forwarded`) and adds the versioned
66
+ aggregate telemetry contract. Existing admission policy fields remain valid.
67
+ - `TrafficWindow.seconds` describes the actual evaluation interval; clients must
68
+ divide counts by it instead of assuming ten seconds. Application evaluation now
69
+ occurs every second; incident evidence accumulates across suspicious windows.
70
+ - Incidents add optional `source`, `sourceId`, `reason`, and `severity`; historical
71
+ incidents without a source represent PocketBase. Edge incidents share the existing
72
+ ID/cursor/acknowledgement API. Old evidence cannot be reconstructed retrospectively.
73
+ - Edge policy adds `bodyReadTimeoutSeconds` (default 5, range 1–10) and
74
+ `maxClientConcurrent` (default min(4, maxConcurrent)). Slow legitimate uploads may
75
+ need an explicit policy adjustment. These limits apply to the public listener.
76
+ - Snapshots/checkpoints remain best-effort and bounded. Retain the security volume
77
+ for incident history. Firewall process counters reset on gateway restart.
78
+
79
+ - The application operator lane now matches the gateway capacity of eight requests,
80
+ allowing parallel admin resources without prematurely exhausting the previous
81
+ four-request application lane. Authentication requirements are unchanged.
82
+
83
+ ## Unified hosting and multinode package
84
+
85
+ Install only `@x47base/pocketbase-addon` for these features. Change Go imports:
86
+
87
+ | Previous import | Combined import |
88
+ |---|---|
89
+ | `github.com/spink-dev/pocketbase-hosting-addon` | `github.com/spink-dev/pocketbase-extension/hosting` |
90
+ | `github.com/spink-dev/pocketbase-multinode-addon` | `github.com/spink-dev/pocketbase-extension/multinode` |
91
+
92
+ Remove the two old Go require/replace entries and npm dependencies after updating imports. Register hosting once, after the base add-on, or use the bundled CLI with `HOSTING_ADDON_CONFIG`. Keep existing JSON configurations, environment names, policy/run collections and data directories. Existing separate package installations are unchanged until migrated; fixes in this combined package do not patch those installations automatically.
93
+
94
+ Fresh CLI configurations now default to `enforce`. Existing persisted policy wins; inspect the Security dashboard when upgrading. Go `DefaultConfig()` retains its prior observe default for embedding compatibility. Explicitly choose enforce for new production hosts.
95
+
96
+ Backup/restore finalizers now share a process-local admission lock even without hosting. Local hosting destinations inside the data tree outside its excluded `backups` directory are rejected. Move such archives offline to an external backup location and update server configuration before enabling hosting; no archive is moved automatically.
97
+
98
+ The gateway strips `X-Spink-Operator` and `X-Real-IP`. Public callers cannot nominate the private operator lane. Request socket deadlines enforce the existing two-minute maximum; streaming clients must reconnect. The bundled gateway accepts at most 512 simultaneous connections. Custom Go hosts must also bound their HTTP listeners.
package/NOTICE.md CHANGED
@@ -3,3 +3,5 @@
3
3
  PocketBase is copyright Gani Georgiev and contributors, licensed under MIT (see LICENSE.md). This extension includes adapted portions of the local PocketBase development fork, notably archive/restore helpers, watcher, custom fields and optional JSVM loader/bindings. The Go dependency is the unmodified upstream v0.40.3 module, not a vendored core fork.
4
4
 
5
5
  The JSVM declarations describe stock core APIs, with trailing whitespace normalized. Extension-specific Go callbacks are documented in their package sources. Upstream issue review history remains in the original local fork's research/2026-09-10 directory; this package's FEATURES.md records delivery scope without claiming upstream acceptance or issue closure.
6
+
7
+ The hosting and multinode source directories were consolidated from the local spink-dev_pocketbase-hosting-addon and spink-dev_pocketbase-multinode-addon repositories. Their prior npm package identities are superseded for this distribution; the original checkouts are preserved.
package/README.md CHANGED
@@ -1,26 +1,66 @@
1
1
  # @x47base/pocketbase-addon
2
2
 
3
- An independent add-on for **unmodified PocketBase v0.40.3**, including a native Security dashboard, encrypted backups and the development extensions listed in [FEATURES.md](FEATURES.md). Supports custom schemas, migrations, and hooks. Go 1.27+; Node 22+ only for npm/JavaScript tests. Co-developed with AI.
3
+ One npm package for stock **PocketBase v0.40.3** extensions: security controls and dashboard, encrypted backups, hosting backup policies, single-owner tenant routing, JavaScript hooks/migrations, and custom field features. See [FEATURES.md](FEATURES.md).
4
4
 
5
+ ## Install and start
6
+
7
+ Requires **Node.js 22+** and **Go 1.27+** on the server/build machine. This npm package includes Go source and builds a server; it is not a browser SDK or a plugin loaded into an existing stock executable. You do not need a GitHub account, checkout, or Git installation.
8
+
9
+ Once this version is published to npm:
5
10
 
6
11
  ```sh
7
- cd /Users/x47base/git/spink-dev_pocketbase-extension
8
- # Keep the admin account/database already created in this session:
9
- go run ./cmd/pocketbase serve --http=127.0.0.1:8090 --dir=/Users/x47base/git/pocketbase/adapter/pb_data
10
- # For a fresh instance instead, use --dir=./pb_data and complete first-run setup.
12
+ mkdir my-pocketbase
13
+ cd my-pocketbase
14
+ npm init -y
15
+ npm install --save-exact @x47base/pocketbase-addon
16
+ npx pocketbase-addon init
17
+ PB_SECURITY_MODE=enforce npx pocketbase-addon serve --http=127.0.0.1:8090 --dir=./pb_data
11
18
  ```
12
19
 
13
- Admin: http://127.0.0.1:8090/_/ **Security**. Backups: **Settings Backups**. Encryption and localized mail: **Settings x47base extensions**. Default security mode is observe; set `PB_SECURITY_MODE=enforce` only after reviewing limits. Stop any existing server on the same port first.
20
+ Open **http://127.0.0.1:8090/_/** and complete first-run superuser setup. `init` writes missing `hosting.json` and `gateway.json` with private file permissions; existing files are preserved. It does not start services or create accounts. Keep the database, configuration and secrets outside `node_modules`.
21
+
22
+ **This checkout has not been published by this change.** To test before publication, run `npm pack` in the package directory and install the resulting `.tgz` in your application with `npm install /absolute/path/to/package.tgz`. The commands above then work unchanged. The tarball includes source, UI assets, examples, deployment tools and documentation.
23
+
24
+ The first build downloads pinned Go dependencies. Every launch runs `go build` using Go's build cache. To require module-proxy downloads without direct GitHub access, set `GOPROXY=https://proxy.golang.org` (without `,direct`); proxy availability is then required. The Go module name remains `github.com/spink-dev/pocketbase-extension` for import compatibility; that name is not an installation instruction.
25
+
26
+ ## Choose features
27
+
28
+ | Feature | How to enable |
29
+ |---|---|
30
+ | Security dashboard, field extensions, backups | Included in `serve`; Security is in the native admin UI. Fresh CLI configurations enforce admission limits. Existing persisted security policy retains precedence. |
31
+ | JavaScript hooks and migrations | `--hooksDir=./pb_hooks --migrationsDir=./pb_migrations`; use `--automigrate=false` in production. |
32
+ | Static content | `--publicDir=./pb_public`; `--staticFallback=` disables fallback. |
33
+ | Hosting backup schedules/dashboard | Set `HOSTING_ADDON_CONFIG=./hosting.json` before `serve`. Off when unset. |
34
+ | Tenant routing gateway | Separate process: `npx pocketbase-addon gateway -config ./gateway.json -listen 127.0.0.1:8095`. |
35
+ | Public API security edge | `npx pocketbase-addon edge -help`; see [deployment guide](deploy/README.md). |
36
+ | Deployment rendering | `npx pocketbase-addon hosting -blueprint ./blueprint.json -out ./generated`. |
37
+
38
+ `pocketbase-extension` remains an alias for existing scripts. `loadtest` and `import-fork` commands remain available. Use `npx pocketbase-addon <command> -help` for gateway/edge/hosting flags and `npx pocketbase-addon serve --help` for PocketBase flags.
39
+
40
+ ### Hosting and encrypted backups
14
41
 
15
42
  ```sh
16
- # From this folder, against your local instance:
17
- go run ./cmd/loadtest -target http://127.0.0.1:8090/api/health -rate 200 -clients 8 -duration 40s -requests 8000
18
- ./scripts/check.sh
19
- npm pack --dry-run
43
+ HOSTING_ADDON_CONFIG=./hosting.json PB_SECURITY_MODE=enforce \
44
+ npx pocketbase-addon serve --http=127.0.0.1:8090 --dir=./pb_data
20
45
  ```
21
46
 
22
- The Go module is `github.com/spink-dev/pocketbase-extension`; the npm package is `@spink-dev/pocketbase-extension`. Neither has been published. Install locally with `npm install /Users/x47base/git/spink-dev_pocketbase-extension`, then `npx pocketbase-extension serve --dir=/absolute/pb_data`. The npm launcher builds the Go binary; this is not a browser-only SDK or a plugin for an already-built stock executable.
47
+ In **Hosting**, create a paused backup policy, run it manually and verify the snapshot before enabling its schedule. The generated configuration uses PocketBase's native backup destination. Additional named local and S3-compatible targets are documented in [hosting](hosting/README.md).
48
+
49
+ For encryption, supply a 32–1024-byte secret via `PB_BACKUP_ENCRYPTION_KEY` or a secret file referenced by `PB_BACKUP_ENCRYPTION_KEY_FILE`. Enable encryption under **Settings → x47base extensions**. Keys stay in the server environment, not records or generated files. Named S3 targets refuse plaintext writes. Keep keys separately from snapshots and test restoration on a disposable copy. Local destinations must be outside `pb_data` or below `pb_data/backups`; recursive destinations are rejected, including symlink aliases.
50
+
51
+ ### Multiple owners
52
+
53
+ The generated gateway config is a **loopback development example**, accepting `Host: localhost:8095` and forwarding to port 8090. It does not enable caching or restrict routes. For production, configure explicit hosts/private origins, TLS ingress and appropriate route restrictions using the [gateway guide](multinode/README.md). Never expose the example origin directly.
54
+
55
+ Every tenant has exactly one authoritative PocketBase owner and its own assigned data partition. Gateways may scale; SQLite writers must not share a data directory. No automatic owner failover, replication, distributed rate limit or guaranteed DDoS capacity is provided. Public caching is opt-in; authenticated requests and writes bypass it. The gateway limits admitted requests, body size, connection count and request lifetime.
56
+
57
+ Store-bound gateway routes and the hosting **commerce blueprint** require the separate Vendure integration and application-level domain guards. Combining hosting/multinode does not add a commerce application to this package. Generic PocketBase usage needs neither Vendure nor PostgreSQL.
58
+
59
+ ## Production and development
23
60
 
24
- See [integration and migration](MIGRATION.md), [feature inventory](FEATURES.md), [verification](VERIFY.md), and [deployment](deploy/README.md). The original development fork is historical; this repository is the extension source. Existing databases stay at their chosen `--dir` and are never moved automatically.
61
+ - Start with [deployment](deploy/README.md) for the private owner/operator listener and public API edge. Container builds from the installed source support `--target pocketbase`, `--target edge`, and `--target gateway`.
62
+ - Read [integration/migration](MIGRATION.md) before changing an existing host or database. No automatic data moves or account resets occur.
63
+ - Run `npm run check` in the package source for Go tests, race tests, vet, JavaScript tests and CLI builds. Tests use disposable databases and local HTTP servers.
64
+ - See [security review](SECURITY-REVIEW.md) for confirmed high-severity findings, trigger scenarios, fixes and verification limits.
25
65
 
26
- Docker deployment with a pre-database gateway, private admin listener and versioned custom migrations: [deployment guide](deploy/README.md). It creates a separate database. Start with `docker compose -f deploy/compose.yaml up --build -d --wait`; admin is on `http://127.0.0.1:8081/_/`.
66
+ Co-developed with AI. MIT license; see [NOTICE.md](NOTICE.md) for third-party notices.
@@ -0,0 +1,96 @@
1
+ # Security and reliability review
2
+
3
+ Reviewed 2026-09-15. Scope: the base extension's authorization/admission, backup/restore and duplication boundaries; hosting configuration, schedules, retention and generated deployments; multinode routing, caching, forwarding and resource admission. Findings below are restricted to **High** severity. No Critical issue was confirmed. This is source review with local regression tests, not a production security certification.
4
+
5
+ ## 1. High — concurrent backup/restore finalizers can operate on the same files
6
+
7
+ **Failure mode:** The pinned upstream `CreateBackup`/`RestoreBackup` entrypoints check `Store.Has` and then call `Store.Set` separately. Both operations are thread-safe individually, but this is not atomic admission. The base extension had no independent lock. Concurrent callers can enter snapshot/restore finalizers together, sharing temporary database filenames, filesystem hook IDs and live data files. One caller can remove shared temporary state or replace files while another is using them. Hosting had its own guard, but users of the base package alone were unprotected.
8
+
9
+ **Trigger:** Two callers both observe no active backup before either stores its active marker. Run backup versus backup, or backup versus restore. This requires overlapping administrative/scheduled/application operations; it is not an unauthenticated endpoint bypass.
10
+
11
+ **Corrected code:** [backups/register.go](backups/register.go) installs the same mutex around both hook chains, before the hosting/encryption/finalizer handlers:
12
+
13
+ ```go
14
+ var operation sync.Mutex
15
+ guard := func(e *core.BackupEvent) error {
16
+ if !operation.TryLock() {
17
+ return errors.New("backup/restore operation already running")
18
+ }
19
+ defer operation.Unlock()
20
+ return e.Next()
21
+ }
22
+ app.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{
23
+ Id: "spink.backup.guard", Priority: -2000, Func: guard,
24
+ })
25
+ app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{
26
+ Id: "spink.restore.guard", Priority: -2000, Func: guard,
27
+ })
28
+ ```
29
+
30
+ **Regression:** `TestBackupAndRestoreHooksExcludeConcurrentOperations` reproduces the state after both callers passed upstream admission and proves the second finalizer is rejected. It fails against the original base implementation. The lock is per registration/process; it does not make shared-data multi-writer operation safe.
31
+
32
+ ## 2. High — public clients can nominate the private operator lane
33
+
34
+ **Failure mode:** The multinode proxy rebuilt standard forwarded headers but preserved `X-Spink-Operator`. The base security layer grants a reserved management lane when that header is `1` and the network peer is in `OperatorPeers`. When a gateway peer is in that configured set, public callers can bypass ordinary admission and occupy operator capacity. This threatens management availability; it does **not** bypass PocketBase superuser authentication.
35
+
36
+ **Trigger:** Configure the owner to accept the multinode gateway's source address as an operator peer, then send an ordinary proxied request with `X-Spink-Operator: 1`. Eight concurrent long requests can consume the reserved lane. The condition does not apply when the gateway peer is outside `OperatorPeers` or a sanitizing edge removes the header first.
37
+
38
+ **Corrected code:** [multinode/gateway.go](multinode/gateway.go), inside proxy `Rewrite`, before rebuilding forwarded identity:
39
+
40
+ ```go
41
+ pr.Out.Header.Del("X-Spink-Operator")
42
+ pr.Out.Header.Del("X-Real-IP")
43
+ pr.SetXForwarded()
44
+ ```
45
+
46
+ **Regression:** `TestGatewayStripsPrivateOperatorIdentity` sends forged private/forwarded identity and checks exactly what the origin receives. It fails against the original gateway.
47
+
48
+ ## 3. High — non-reading clients can permanently hold gateway admission slots
49
+
50
+ **Failure mode:** A two-minute request context cancels upstream work but does not interrupt a blocked write to the downstream socket. The original gateway server had no write timeout. Once an upstream response fills socket buffers for a client that stops reading, `ReverseProxy` can remain in `Write` beyond the context deadline, retaining its admission slot. Exhausting the default 128 slots prevents ordinary service. Connections themselves were also unbounded at the listener.
51
+
52
+ **Trigger:** Open requests to a configured tenant endpoint returning a sufficiently large response, then stop reading while keeping TCP open. Repeat up to the configured `maxConcurrent`. The regression uses one slot, a bounded large response and a shortened request context to reproduce it locally without a load attack.
53
+
54
+ **Corrected code:** [multinode/gateway.go](multinode/gateway.go), after deriving the existing bounded request context:
55
+
56
+ ```go
57
+ deadline, _ := ctx.Deadline()
58
+ controller := http.NewResponseController(w)
59
+ _ = controller.SetReadDeadline(deadline)
60
+ _ = controller.SetWriteDeadline(deadline)
61
+ defer controller.SetReadDeadline(time.Time{})
62
+ defer controller.SetWriteDeadline(time.Time{})
63
+ ```
64
+
65
+ [cmd/gateway/main.go](cmd/gateway/main.go) also serves through `netutil.LimitListener(listener, 512)`. Standard Go HTTP writers support these deadlines; custom embedding writers must preserve `ResponseController` support. Streaming clients must reconnect within the existing two-minute lifetime.
66
+
67
+ **Regressions:** `TestSlowReaderReleasesAdmissionSlot` uses a real TCP client that does not read; `TestGatewaySetsSocketDeadlinesForAdmittedRequests` checks deadline setup/reset. Both fail against the original gateway.
68
+
69
+ ## 4. High — local backup targets can recursively archive prior backups
70
+
71
+ **Failure mode:** Hosting validated only that a local path was absolute. A target such as `/data/archives` is inside the directory PocketBase snapshots and outside its excluded `/data/backups` subtree. Every later snapshot includes previous archives. With retention greater than one, nested archive growth can exhaust the owner's disk, stopping both writes and backups. Retention runs only after creating the next snapshot, too late to prevent exhaustion.
72
+
73
+ **Trigger:** With `--dir=/data`, configure a named local target `/data/archives`, schedule repeated backups and keep multiple snapshots. A symlink outside the data directory pointing into that subtree has the same effect.
74
+
75
+ **Corrected code:** [hosting/config.go](hosting/config.go) adds `resolvedPath` and `validateLocalTarget`, resolving existing ancestors for destinations that do not yet exist. Only physical paths outside the data tree or under its excluded `backups` subtree pass. [hosting/backups.go](hosting/backups.go) applies this at startup and every destination opening:
76
+
77
+ ```go
78
+ if err := validateLocalTarget(app.DataDir(), t.Path); err != nil {
79
+ return nil, err
80
+ }
81
+ ```
82
+
83
+ **Regression:** `TestLocalTargetCannotIncludeBackupsInNextSnapshot` covers the data root, nested paths, symlink aliases, allowed external paths and allowed native-backup descendants. Server-controlled filesystem paths remain a trusted configuration boundary; do not allow untrusted local users to change their ancestors concurrently.
84
+
85
+ ## Other reviewed boundaries
86
+
87
+ - Management/settings/hosting routes require native superuser authentication. Record duplication checks view/protected-file access and invokes the native create pipeline. No new authorization bypass was confirmed in these paths.
88
+ - Gateway destinations come from validated server configuration, not request URLs. Unknown hosts fail closed. Cache keys separate hosts and paths; credentialed/variant requests and private responses bypass caching. Mutations have no application retry/failover.
89
+ - Named S3 destinations use configured HTTPS endpoints and credential environment references; plaintext backup uploads are rejected. Restore authenticates encrypted content before extraction and retains cancellation/rollback handling. Existing ZIP path traversal defenses remain.
90
+ - Configuration/route counts, cache size, bodies, request admission and policy query sizes have explicit bounds. Snapshot size, backup history and deployment capacity remain operational concerns, not unlimited-capacity guarantees.
91
+
92
+ ## Delivery and verification
93
+
94
+ Fixes live in the combined `@x47base/pocketbase-addon` source. The two original standalone checkouts are preserved; their installations must migrate to receive these patches. The root README and MIGRATION guide describe npm tarball installation and changed imports. No upstream PocketBase source/module cache was modified.
95
+
96
+ See the current verification section in [VERIFY.md](VERIFY.md). Local checks do not establish public TLS, real cloud storage delivery, Kubernetes admission/scheduling, browser UI correctness, volumetric DDoS resistance or a real production restore drill. No npm publication or provider deployment is part of this change.
package/VERIFY.md ADDED
@@ -0,0 +1,34 @@
1
+ # Verification — unified package, 2026-09-15
2
+
3
+ - `GOWORK=off sh scripts/check.sh`: passed. Includes all-package Go tests, targeted race tests (including hosting and multinode), `go vet ./...`, 13 JavaScript tests, and builds of PocketBase, edge, gateway and hosting commands.
4
+ - New backup admission and gateway operator/deadline/slow-reader regressions fail against the original implementations and pass against the patched combined source. Slow-reader coverage uses an actual TCP connection and a short fixture timeout, not a public load test.
5
+ - Local target validation covers missing descendants, symlink aliases and allowed external/native-backup destinations. Generated gateway routes are validated against the gateway's actual configuration contract, including the 64-character store bound.
6
+ - npm tarball inventory includes hosting/multinode source, UI, examples, tools and guides, and excludes live databases, environment files and Git metadata.
7
+ - A fresh consumer installed the `.tgz` with `npm install --offline --ignore-scripts`. With `GOPROXY=off` and previously cached Go dependencies, the installed launcher generated configuration and rendered manifests, started an owner and gateway on loopback, proxied a real health request, created hosting collections and denied unauthenticated hosting API access. Temporary processes were stopped.
8
+ - No npm publication, Docker image build, provider deployment, Kubernetes scheduling, browser UI check or new process-restarting restore drill was performed for this consolidation. Historical results below are previous verification, not rerun claims.
9
+
10
+ ## Historical verification
11
+
12
+ # Verification — 2026-09-10
13
+
14
+ - Independent `GOWORK=off` module resolves unmodified `github.com/pocketbase/pocketbase v0.40.3`, with no upstream replacement. Original fork is not required.
15
+ - Go tests for all packages, race checks for security/features/localization/loadtest/backups/settings/mail/admin/OTP/watcher, vet, Node model/registration tests and binary build passed.
16
+ - Migrated regression cases cover schema precision and remote-reference denial, duplicate source/create/protected-file rules and cloned file contents, static fallback, authorized image dimensions, first-run environment settings, watcher coalescing, settings revision conflicts, locale isolation and native OTP single-use.
17
+ - Encryption tests cover age round trip, wrong/missing keys, tampering, plaintext downgrade, cancellation, private staging and S3-compatible HTTP destination receiving ciphertext only. This is an isolated S3-compatible fixture, not a live cloud-provider verification.
18
+ - Restore swap tests inject second-move and transaction-commit failures and cancellation; originals are restored. A later registered ordinary-priority application restore hook still runs.
19
+ - Real isolated process drill: create database/record → encrypted backup → mutate record → restore through native API → process restart → original record restored, same admin token valid. Temporary database and process were removed.
20
+ - Browser reproduced missing Security on the existing 127.0.0.1 session. Versioned upstream entry/extension loader restored the native Security link; overview loaded without a new login. Server HTML retains stock CSP. No generated upstream source or bundle was changed.
21
+ - Final browser review confirmed Settings → x47base extensions exposes encryption/key-availability, encrypted upload and localized-template controls; the Security overview remains reachable with the existing session.
22
+ - npm package dry run includes Go sources, UI, launcher and required module/type assets, excludes database files. npm CLI builds and displays help; the extracted npm archive builds independently. Nothing pushed or published.
23
+ - Dockerfile and GitHub workflow supplied; neither was run remotely. No production DDoS capacity, automatic attacker attribution, or future-version compatibility claim.
24
+
25
+ ## Docker edge add-on
26
+
27
+ - Full Go tests, race checks (including edge), vet, Node tests and both binaries pass.
28
+ - Both Docker image targets build locally; isolated Compose deployment reaches healthy on ports 18080/18081 with fresh named volumes. No existing development database is attached.
29
+ - Gateway regression tests prove zero upstream calls for blocked networks/paths/families, unknown/admin routes, ambiguous paths, malformed policy and oversized/chunked bodies. Tests cover client bounds, unchanged-reload budgets, stale checkpoints, action expiry, forwarded-header spoofing, and independent operator capacity.
30
+ - Native operator tests require an explicit immediate peer and marker; untrusted requests remain blocked and native superuser authentication remains required.
31
+ - Real Docker drill: checked-in notes migration applies; authenticated public read succeeds; a temporary dashboard API block synchronizes; 100 blocked reads produce zero forwarded requests; operator reads remain available; removing the block recovers public reads. Native backup → mutate → restore succeeds inside the container with the separate state volume.
32
+ - Additional finite smoke: 200 public admin-path requests all rejected, zero public accepted delta, liveness and readiness canaries pass.
33
+ - npm package inspection includes edge sources, Dockerfile, Compose, migration and deployment guide. CI now builds images and runs a disposable Compose smoke; remote CI has not run.
34
+ - These are functional, local checks, not a production capacity benchmark or proof against volumetric DDoS. Gateway policy sync is asynchronous and the documented stale checkpoint bound applies.
@@ -0,0 +1,43 @@
1
+ package backups
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "testing"
9
+ )
10
+
11
+ func TestBackupAndRestoreHooksExcludeConcurrentOperations(t *testing.T) {
12
+ app, err := tests.NewTestApp()
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ defer app.Cleanup()
17
+ Register(app, func(core.App) (Config, error) { return Config{}, nil })
18
+ entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
19
+ event := func() *core.BackupEvent {
20
+ return &core.BackupEvent{App: app, Context: context.Background(), Name: "backup.zip"}
21
+ }
22
+ go func() {
23
+ done <- app.OnBackupCreate().Trigger(event(), func(e *core.BackupEvent) error { close(entered); <-release; return nil })
24
+ }()
25
+ <-entered
26
+ defer func() {
27
+ close(release)
28
+ if err := <-done; err != nil {
29
+ t.Error(err)
30
+ }
31
+ }()
32
+ // Trigger represents two callers already past the upstream Has/Set busy check.
33
+ called := false
34
+ err = app.OnBackupCreate().Trigger(event(), func(e *core.BackupEvent) error { called = true; return nil })
35
+ if err == nil || called {
36
+ t.Error("concurrent backup entered snapshot finalizer")
37
+ }
38
+ app.OnBackupRestore().BindFunc(func(e *core.BackupEvent) error { called = true; return errors.New("restore reached") })
39
+ called = false
40
+ if err = app.OnBackupRestore().Trigger(event()); err == nil || called {
41
+ t.Error("restore entered while snapshot was active")
42
+ }
43
+ }
@@ -9,6 +9,7 @@ import (
9
9
  "os"
10
10
  "path/filepath"
11
11
  "strings"
12
+ "sync"
12
13
  "time"
13
14
  )
14
15
 
@@ -38,6 +39,16 @@ func (a *stagedApp) NewBackupsFilesystem() (*filesystem.System, error) {
38
39
  return filesystem.NewLocal(a.dir)
39
40
  }
40
41
  func Register(app core.App, config func(core.App) (Config, error)) {
42
+ var operation sync.Mutex
43
+ guard := func(e *core.BackupEvent) error {
44
+ if !operation.TryLock() {
45
+ return errors.New("backup/restore operation already running")
46
+ }
47
+ defer operation.Unlock()
48
+ return e.Next()
49
+ }
50
+ app.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{Id: "spink.backup.guard", Priority: -2000, Func: guard})
51
+ app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{Id: "spink.restore.guard", Priority: -2000, Func: guard})
41
52
  app.OnBackupCreate().BindFunc(func(e *core.BackupEvent) error {
42
53
  c, err := config(e.App)
43
54
  if err != nil {
@@ -0,0 +1,31 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {realpathSync,mkdtempSync,readFileSync,writeFileSync,mkdirSync,rmSync} from 'node:fs';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+ import {spawnSync} from 'node:child_process';
7
+ import {fileURLToPath} from 'node:url';
8
+ const cli=fileURLToPath(new URL('./pocketbase-extension.mjs',import.meta.url));
9
+ function fixture(t){const dir=realpathSync(mkdtempSync(join(tmpdir(),'addon-cli-test-')));t.after(()=>rmSync(dir,{recursive:true,force:true}));return dir;}
10
+ test('init creates private configs and preserves operator edits',t=>{
11
+ const dir=fixture(t);
12
+ const run=()=>spawnSync(process.execPath,[cli,'init'],{cwd:dir,encoding:'utf8'});
13
+ assert.equal(run().status,0);
14
+ assert.deepEqual(JSON.parse(readFileSync(join(dir,'hosting.json'))),{targets:[{key:'native',kind:'native'}]});
15
+ assert.deepEqual(JSON.parse(readFileSync(join(dir,'gateway.json'))).tenants[0],{host:'localhost:8095',origin:'http://127.0.0.1:8090'});
16
+ writeFileSync(join(dir,'hosting.json'),'operator config');
17
+ assert.equal(run().status,0);
18
+ assert.equal(readFileSync(join(dir,'hosting.json'),'utf8'),'operator config');
19
+ });
20
+ for(const command of ['hosting','gateway','edge','loadtest','import-fork','serve'])test(`launcher dispatches ${command} from caller directory`,t=>{
21
+ const dir=fixture(t),bin=join(dir,'bin');mkdirSync(bin);
22
+ writeFileSync(join(bin,'go'),`#!${process.execPath}\nimport fs from 'node:fs';\nconst a=process.argv.slice(2);fs.writeFileSync(process.env.BUILD_LOG,JSON.stringify({args:a,cwd:process.cwd(),gowork:process.env.GOWORK}));fs.writeFileSync(a[a.indexOf('-o')+1],'#!${process.execPath}\\nconsole.log(JSON.stringify({args:process.argv.slice(2),cwd:process.cwd()}))',{mode:0o700});`,{mode:0o700});
23
+ const result=spawnSync(process.execPath,[cli,command,'--help'],{cwd:dir,encoding:'utf8',env:{...process.env,PATH:bin+':'+process.env.PATH,BUILD_LOG:join(dir,'build.json')}});
24
+ assert.equal(result.status,0,result.stderr);
25
+ const build=JSON.parse(readFileSync(join(dir,'build.json')));
26
+ assert.equal(build.args.at(-1),'./cmd/'+(command==='serve'?'pocketbase':command));
27
+ assert.equal(build.gowork,'off');
28
+ const child=JSON.parse(result.stdout);
29
+ assert.equal(child.cwd,dir);
30
+ assert.deepEqual(child.args,command==='serve'?['serve','--help','--dir='+join(dir,'pb_data')]:['--help']);
31
+ });
@@ -1,13 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  import {spawn,spawnSync} from 'node:child_process';
3
- import {mkdtempSync,rmSync} from 'node:fs';
3
+ import {mkdtempSync,rmSync,mkdirSync,writeFileSync} from 'node:fs';
4
4
  import {tmpdir} from 'node:os';
5
5
  import {fileURLToPath} from 'node:url';
6
6
  import {resolve,join} from 'node:path';
7
7
  const root=fileURLToPath(new URL('..',import.meta.url));
8
8
  const args=process.argv.slice(2);
9
+ if(args[0]==='init'){
10
+ const dir=resolve(process.cwd(),args[1]||'.');
11
+ mkdirSync(dir,{recursive:true});
12
+ const files={
13
+ 'hosting.json':JSON.stringify({targets:[{key:'native',kind:'native'}]},null,2)+'\n',
14
+ 'gateway.json':JSON.stringify({allowHTTP:true,tenants:[{host:'localhost:8095',origin:'http://127.0.0.1:8090'}]},null,2)+'\n'
15
+ };
16
+ for(const [name,content] of Object.entries(files)){
17
+ try{writeFileSync(join(dir,name),content,{flag:'wx',mode:0o600});}
18
+ catch(error){if(error.code!=='EEXIST')throw error;}
19
+ }
20
+ console.log('Created missing hosting.json and gateway.json. See the packaged README for startup and private-origin deployment.');
21
+ process.exit(0);
22
+ }
9
23
  let target='pocketbase';
10
- if(args[0]==='loadtest'||args[0]==='import-fork')target=args.shift();
24
+ if(['loadtest','import-fork','hosting','gateway','edge'].includes(args[0]))target=args.shift();
11
25
  if(target==='pocketbase'&&args.length===0)args.push('serve');
12
26
  let hasDir=false;
13
27
  for(let i=0;i<args.length;i++){
@@ -21,5 +35,5 @@ const result=spawnSync('go',['build','-o',binary,'./cmd/'+target],{cwd:root,stdi
21
35
  if(result.error||result.status!==0){rmSync(temp,{recursive:true,force:true});console.error('Building the extension requires Go 1.27+.',result.error?.message||'');process.exit(result.status||1);}
22
36
  const child=spawn(binary,args,{cwd:process.cwd(),stdio:'inherit',env:process.env});
23
37
  child.on('error',error=>{rmSync(temp,{recursive:true,force:true});console.error(error.message);process.exitCode=1;});
24
- for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
38
+ for(const signal of ['SIGINT','SIGTERM',...(target==='gateway'?['SIGHUP']:[])])process.on(signal,()=>child.kill(signal));
25
39
  child.on('exit',(code,signal)=>{rmSync(temp,{recursive:true,force:true});process.exitCode=code??(signal?1:0)});
package/cmd/edge/main.go CHANGED
@@ -22,6 +22,7 @@ func main() {
22
22
  operator := flag.String("operator", ":8081", "Operator listener; publish on loopback only")
23
23
  policyPath := flag.String("policy", "/etc/spink/edge.json", "Edge policy file")
24
24
  checkpoint := flag.String("checkpoint", "/state/state.json", "Read-only security checkpoint")
25
+ telemetryPath := flag.String("telemetry", "", "Aggregate telemetry output file; use a separate shared volume")
25
26
  probe := flag.String("probe", "", "Health URL to check and exit")
26
27
  flag.Parse()
27
28
  if *probe != "" {
@@ -54,6 +55,28 @@ func main() {
54
55
  }
55
56
  ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
56
57
  defer stop()
58
+ if *telemetryPath != "" {
59
+ go func() {
60
+ tick := time.NewTicker(time.Second)
61
+ defer tick.Stop()
62
+ failed := false
63
+ for {
64
+ err := g.WriteTelemetry(*telemetryPath)
65
+ if err != nil && !failed {
66
+ slog.Error("edge telemetry unavailable", "error", err)
67
+ }
68
+ if err == nil && failed {
69
+ slog.Info("edge telemetry restored")
70
+ }
71
+ failed = err != nil
72
+ select {
73
+ case <-ctx.Done():
74
+ return
75
+ case <-tick.C:
76
+ }
77
+ }
78
+ }()
79
+ }
57
80
  go func() {
58
81
  ticker := time.NewTicker(time.Second)
59
82
  defer ticker.Stop()
@@ -120,4 +143,9 @@ func main() {
120
143
  for _, s := range servers {
121
144
  s.Shutdown(shutdown)
122
145
  }
146
+ if *telemetryPath != "" {
147
+ if err := g.WriteTelemetry(*telemetryPath); err != nil {
148
+ slog.Error("final telemetry write", "error", err)
149
+ }
150
+ }
123
151
  }
@@ -0,0 +1,84 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "flag"
6
+ gateway "github.com/spink-dev/pocketbase-extension/multinode"
7
+ "golang.org/x/net/netutil"
8
+ "log"
9
+ "net"
10
+ "net/http"
11
+ "os"
12
+ "os/signal"
13
+ "sync/atomic"
14
+ "syscall"
15
+ "time"
16
+ )
17
+
18
+ func main() {
19
+ path := flag.String("config", "gateway.json", "routing configuration")
20
+ addr := flag.String("listen", "127.0.0.1:8095", "listen address")
21
+ flag.Parse()
22
+ f, err := os.Open(*path)
23
+ if err != nil {
24
+ log.Fatal(err)
25
+ }
26
+ config, err := gateway.Load(f)
27
+ f.Close()
28
+ if err != nil {
29
+ log.Fatal(err)
30
+ }
31
+ g, err := gateway.New(config)
32
+ if err != nil {
33
+ log.Fatal(err)
34
+ }
35
+ var current atomic.Pointer[gateway.Gateway]
36
+ current.Store(g)
37
+ s := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { current.Load().ServeHTTP(w, r) }), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 32 << 10}
38
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
39
+ defer stop()
40
+ reload := make(chan os.Signal, 1)
41
+ signal.Notify(reload, syscall.SIGHUP)
42
+ defer signal.Stop(reload)
43
+ go func() {
44
+ for {
45
+ select {
46
+ case <-ctx.Done():
47
+ return
48
+ case <-reload:
49
+ f, err := os.Open(*path)
50
+ if err != nil {
51
+ log.Print("routing reload failed")
52
+ continue
53
+ }
54
+ config, err := gateway.Load(f)
55
+ f.Close()
56
+ if err != nil {
57
+ log.Print("routing reload invalid")
58
+ continue
59
+ }
60
+ next, err := gateway.New(config)
61
+ if err != nil {
62
+ log.Print("routing reload invalid")
63
+ continue
64
+ }
65
+ current.Swap(next).CloseIdleConnections()
66
+ log.Print("routing reloaded")
67
+ }
68
+ }
69
+ }()
70
+ go func() {
71
+ <-ctx.Done()
72
+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
73
+ defer cancel()
74
+ s.Shutdown(shutdown)
75
+ }()
76
+ log.Printf("gateway listening on %s", *addr)
77
+ listener, err := net.Listen("tcp", *addr)
78
+ if err != nil {
79
+ log.Fatal(err)
80
+ }
81
+ if err = s.Serve(netutil.LimitListener(listener, 512)); err != nil && err != http.ErrServerClosed {
82
+ log.Fatal(err)
83
+ }
84
+ }