@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,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ const [file, mode = "plan"] = process.argv.slice(2);
4
+ if (!file || !["plan", "apply"].includes(mode))
5
+ throw Error("Usage: node scripts/dns.mjs dns.json [plan|apply]");
6
+ const plan = JSON.parse(readFileSync(file));
7
+ if (
8
+ !["cloudflare", "route53"].includes(plan.provider) ||
9
+ !/^[A-Za-z0-9]{1,64}$/.test(plan.zoneID) ||
10
+ !Array.isArray(plan.records) ||
11
+ plan.records.length < 1 ||
12
+ plan.records.length > 100
13
+ )
14
+ throw Error("Invalid DNS plan");
15
+ const domain = /^(?=.{1,253}$)[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/;
16
+ for (const r of plan.records) {
17
+ if (
18
+ !domain.test(r.name) ||
19
+ !["TXT", "CNAME", "A", "AAAA"].includes(r.type) ||
20
+ typeof r.content !== "string" ||
21
+ r.content.length > 2048 ||
22
+ /[\r\n]/.test(r.content) ||
23
+ !Number.isInteger(r.ttl) ||
24
+ r.ttl < 60 ||
25
+ r.ttl > 86400
26
+ )
27
+ throw Error("Invalid DNS record");
28
+ }
29
+ if (mode === "plan") {
30
+ console.log(
31
+ JSON.stringify(
32
+ {
33
+ provider: plan.provider,
34
+ zoneID: plan.zoneID,
35
+ operation:
36
+ "create only; existing different records are not overwritten",
37
+ records: plan.records,
38
+ },
39
+ null,
40
+ 2,
41
+ ),
42
+ );
43
+ process.exit(0);
44
+ }
45
+ if (plan.provider === "route53") {
46
+ const batch = {
47
+ Changes: plan.records.map((r) => ({
48
+ Action: "CREATE",
49
+ ResourceRecordSet: {
50
+ Name: r.name,
51
+ Type: r.type,
52
+ TTL: r.ttl,
53
+ ResourceRecords: [
54
+ { Value: r.type === "TXT" ? JSON.stringify(r.content) : r.content },
55
+ ],
56
+ },
57
+ })),
58
+ };
59
+ const result = spawnSync(
60
+ "aws",
61
+ [
62
+ "route53",
63
+ "change-resource-record-sets",
64
+ "--hosted-zone-id",
65
+ plan.zoneID,
66
+ "--change-batch",
67
+ JSON.stringify(batch),
68
+ ],
69
+ { stdio: "inherit", shell: false },
70
+ );
71
+ if (result.status !== 0)
72
+ throw Error("Route 53 create failed; inspect DNS state before repeating");
73
+ } else {
74
+ if (
75
+ !/^[A-Z][A-Z0-9_]{0,127}$/.test(plan.tokenEnv) ||
76
+ !process.env[plan.tokenEnv]
77
+ )
78
+ throw Error("Cloudflare API token environment reference required");
79
+ const base = `https://api.cloudflare.com/client/v4/zones/${plan.zoneID}/dns_records`;
80
+ const headers = {
81
+ Authorization: "Bearer " + process.env[plan.tokenEnv],
82
+ "Content-Type": "application/json",
83
+ };
84
+ for (const r of plan.records) {
85
+ const lookup = await fetch(
86
+ base + "?" + new URLSearchParams({ name: r.name, type: r.type }),
87
+ { headers, redirect: "error", signal: AbortSignal.timeout(15000) },
88
+ );
89
+ const existing = await lookup.json();
90
+ if (!lookup.ok || !existing.success) throw Error("DNS lookup failed");
91
+ if (existing.result.length) {
92
+ if (existing.result.some((v) => v.content !== r.content))
93
+ throw Error("Existing record differs; no overwrite performed");
94
+ continue;
95
+ }
96
+ const response = await fetch(base, {
97
+ method: "POST",
98
+ headers,
99
+ body: JSON.stringify({
100
+ name: r.name,
101
+ type: r.type,
102
+ content: r.content,
103
+ ttl: r.ttl,
104
+ proxied: false,
105
+ }),
106
+ redirect: "error",
107
+ signal: AbortSignal.timeout(15000),
108
+ });
109
+ const data = await response.json();
110
+ if (!response.ok || !data.success)
111
+ throw Error("DNS create failed; inspect records before repeating");
112
+ }
113
+ console.log(
114
+ "DNS create plan applied. Verify ownership in PocketBase, then export routes and provision TLS.",
115
+ );
116
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ const [url, blueprintFile, output] = process.argv.slice(2);
3
+ if (!url || !blueprintFile || !output)
4
+ throw Error(
5
+ "Usage: HOSTING_PB_TOKEN=... node scripts/routes.mjs https://admin.example blueprint.json output.json",
6
+ );
7
+ const endpoint = new URL(url);
8
+ if (
9
+ endpoint.username ||
10
+ endpoint.password ||
11
+ endpoint.search ||
12
+ endpoint.hash ||
13
+ endpoint.pathname !== "/" ||
14
+ !(
15
+ endpoint.protocol === "https:" ||
16
+ (endpoint.protocol === "http:" &&
17
+ ["127.0.0.1", "localhost"].includes(endpoint.hostname))
18
+ )
19
+ )
20
+ throw Error("Fixed HTTPS management origin required");
21
+ if (!process.env.HOSTING_PB_TOKEN) throw Error("HOSTING_PB_TOKEN required");
22
+ const res = await fetch(new URL("/api/vendure/domain-routes", endpoint), {
23
+ headers: { Authorization: process.env.HOSTING_PB_TOKEN },
24
+ redirect: "error",
25
+ signal: AbortSignal.timeout(15000),
26
+ });
27
+ if (!res.ok) throw Error("Route export failed");
28
+ const { items } = await res.json();
29
+ if (!Array.isArray(items) || !items.length)
30
+ throw Error("No verified, open-store domain routes available");
31
+ const blueprint = JSON.parse(readFileSync(blueprintFile));
32
+ blueprint.domains = items.map(({ host, store }) => ({ host, store }));
33
+ writeFileSync(output + ".tmp", JSON.stringify(blueprint, null, 2) + "\n", {
34
+ mode: 0o600,
35
+ });
36
+ renameSync(output + ".tmp", output);
37
+ console.log(
38
+ "Verified domain routes exported. Render and review before gateway reload.",
39
+ );
@@ -0,0 +1,36 @@
1
+ const base = app.pb.buildURL("/_/extensions/x47-hosting/");
2
+ const css = document.createElement("link");
3
+ css.rel = "stylesheet";
4
+ css.href = base + "page.css";
5
+ document.head.append(css);
6
+ app.routes.superuserOnly("#/hosting", () => {
7
+ let cancelled = false,
8
+ dispose = () => {};
9
+ return t.div(
10
+ { className: "page x47-hosting" },
11
+ t.div({
12
+ className: "page-content",
13
+ onmount: async (root) => {
14
+ root.textContent = "Loading backup policies…";
15
+ try {
16
+ const { mount } = await import(base + "page.js");
17
+ if (!cancelled && root.isConnected) dispose = mount(root, app);
18
+ } catch {
19
+ if (!cancelled)
20
+ root.textContent =
21
+ "Hosting could not load. Reload the dashboard to try again.";
22
+ }
23
+ },
24
+ onunmount: () => {
25
+ cancelled = true;
26
+ dispose();
27
+ },
28
+ }),
29
+ );
30
+ });
31
+ if (!app.store.headerLinks.some((x) => x.href === "#/hosting"))
32
+ app.store.headerLinks.push({
33
+ href: "#/hosting",
34
+ label: "Hosting",
35
+ icon: "ri-server-line",
36
+ });
@@ -0,0 +1,86 @@
1
+ .x47-hosting .page-content {
2
+ max-width: 1100px;
3
+ margin: 0 auto;
4
+ padding: 32px;
5
+ }
6
+ .x47-hosting h1 {
7
+ font-size: 28px;
8
+ margin-bottom: 16px;
9
+ }
10
+ .x47-hosting h2 {
11
+ font-size: 20px;
12
+ margin: 24px 0 16px;
13
+ }
14
+ .x47-hosting p {
15
+ line-height: 1.6;
16
+ margin: 10px 0;
17
+ }
18
+ .hosting-card {
19
+ border: 1px solid var(--base-alt-2, #ddd);
20
+ border-radius: 12px;
21
+ padding: 20px;
22
+ margin: 16px 0;
23
+ }
24
+ .hosting-card h2 {
25
+ margin-top: 0;
26
+ }
27
+ .hosting-grid {
28
+ display: grid;
29
+ grid-template-columns: repeat(2, minmax(0, 1fr));
30
+ gap: 18px;
31
+ }
32
+ .hosting-grid label {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 8px;
36
+ }
37
+ .hosting-grid input,
38
+ .hosting-grid select {
39
+ width: 100%;
40
+ }
41
+ .hosting-grid .hosting-check {
42
+ flex-direction: row;
43
+ align-items: center;
44
+ }
45
+ .hosting-check input {
46
+ width: auto;
47
+ }
48
+ .hosting-table {
49
+ overflow: auto;
50
+ }
51
+ .hosting-table th,
52
+ .hosting-table td {
53
+ text-align: left;
54
+ padding: 12px;
55
+ vertical-align: top;
56
+ }
57
+ .hosting-table code {
58
+ overflow-wrap: anywhere;
59
+ }
60
+ #hostingNotice {
61
+ color: var(--primary-color, #18655d);
62
+ }
63
+ @media (max-width: 650px) {
64
+ .hosting-grid {
65
+ grid-template-columns: 1fr;
66
+ }
67
+ .x47-hosting .page-content {
68
+ padding: 16px;
69
+ }
70
+ }
71
+
72
+ .hosting-grid input:not([type="checkbox"]),
73
+ .hosting-grid select {
74
+ min-height: 42px;
75
+ padding: 8px 12px;
76
+ border: 1px solid var(--base-alt-2, #c8cdd2);
77
+ border-radius: 6px;
78
+ background: var(--base-color, #fff);
79
+ color: inherit;
80
+ font: inherit;
81
+ }
82
+ .hosting-grid input:focus,
83
+ .hosting-grid select:focus {
84
+ outline: 2px solid var(--primary-color, #18655d);
85
+ outline-offset: 2px;
86
+ }
@@ -0,0 +1,112 @@
1
+ export function mount(root, app) {
2
+ let disposed = false,
3
+ busy = false,
4
+ editing = null;
5
+ const escape = (x) =>
6
+ String(x ?? "").replace(
7
+ /[&<>"']/g,
8
+ (c) =>
9
+ ({
10
+ "&": "&amp;",
11
+ "<": "&lt;",
12
+ ">": "&gt;",
13
+ '"': "&quot;",
14
+ "'": "&#39;",
15
+ })[c],
16
+ );
17
+ const api = (path, method = "GET", body) =>
18
+ app.pb.send("/api/hosting/" + path, { method, body, requestKey: null });
19
+ async function load() {
20
+ const data = await api("backups");
21
+ if (disposed) return;
22
+ editing = null;
23
+ root.innerHTML = `<h1>Hosting & backups</h1><p>Snapshots protect this entire PocketBase database and all its stores. Vendure/PostgreSQL requires a separate backup policy.</p><p>Targets are configured on the server. Named S3 targets require encrypted backups. Restore snapshots through the platform administrator's backup recovery workflow.</p><p role="status" id="hostingNotice"></p>
24
+ <section class="hosting-card"><h2 id="policyHeading">Create a backup policy</h2><form id="backupPolicy" class="hosting-grid"><label>Name<input name="name" maxlength="128" required></label><label>Target<select name="target">${data.targets.map((t) => `<option value="${escape(t.key)}">${escape(t.key)} (${escape(t.kind)})</option>`).join("")}</select></label><label>Interval (minutes)<input name="intervalMinutes" type="number" min="5" max="43200" value="60" required></label><label>Keep snapshots<input name="keep" type="number" min="1" max="1000" value="24" required></label><label class="hosting-check"><input name="enabled" type="checkbox"> Enable schedule</label><div><button class="btn btn-primary" type="submit">Save policy</button> <button class="btn" id="cancelEdit" type="button" hidden>Cancel edit</button></div></form></section>
25
+ <h2>Backup policies</h2>${data.policies.length ? data.policies.map((p) => `<section class="hosting-card"><h3>${escape(p.name)}</h3><p>${escape(p.target)} · every ${p.intervalMinutes} minutes · keep ${p.keep} · <strong>${p.enabled ? "Enabled" : "Paused"}</strong> · ${escape(p.lastStatus || "No runs yet")}</p><p>Next scheduled run: ${p.enabled ? escape(p.nextRun) : "Paused"}</p><button class="btn" data-edit="${p.id}">Edit policy</button> <button class="btn" data-run="${p.id}">Run backup now</button> <button class="btn" data-toggle="${p.id}">${p.enabled ? "Pause" : "Enable"}</button></section>`).join("") : "<p>No policies yet. Create one above, then run your first snapshot.</p>"}
26
+ <h2>Recent runs</h2>${data.runs.length ? '<div class="hosting-table"><table><thead><tr><th>Status</th><th>Started</th><th>Snapshot</th><th>Details</th></tr></thead><tbody>' + data.runs.map((r) => `<tr><td>${escape(r.status)}</td><td>${escape(r.created)}</td><td><code>${escape(r.key)}</code></td><td>${escape(r.message || "—")}</td></tr>`).join("") + "</tbody></table></div>" : "<p>No backups have run yet.</p>"}`;
27
+ const form = root.querySelector("#backupPolicy");
28
+ form.onsubmit = (e) => {
29
+ e.preventDefault();
30
+ run(async () => {
31
+ const values = Object.fromEntries(new FormData(form));
32
+ values.intervalMinutes = Number(values.intervalMinutes);
33
+ values.keep = Number(values.keep);
34
+ values.enabled = form.elements.enabled.checked;
35
+ await api(
36
+ "backups/policies" + (editing ? "/" + editing : ""),
37
+ editing ? "PUT" : "POST",
38
+ values,
39
+ );
40
+ await load();
41
+ });
42
+ };
43
+ root.querySelector("#cancelEdit").onclick = () => run(load);
44
+ root.querySelectorAll("[data-edit]").forEach(
45
+ (b) =>
46
+ (b.onclick = () => {
47
+ if (busy) return;
48
+ const p = data.policies.find((p) => p.id === b.dataset.edit);
49
+ editing = p.id;
50
+ for (const key of ["name", "target", "intervalMinutes", "keep"])
51
+ form.elements[key].value = p[key];
52
+ form.elements.enabled.checked = p.enabled;
53
+ root.querySelector("#policyHeading").textContent =
54
+ "Edit backup policy";
55
+ root.querySelector("#cancelEdit").hidden = false;
56
+ form.elements.name.focus();
57
+ }),
58
+ );
59
+ root.querySelectorAll("[data-run]").forEach(
60
+ (b) =>
61
+ (b.onclick = () =>
62
+ run(async () => {
63
+ root.querySelector("#hostingNotice").textContent =
64
+ "Creating snapshot… This may take several minutes. Keep this page open.";
65
+ await api("backups/policies/" + b.dataset.run + "/run", "POST", {});
66
+ await load();
67
+ })),
68
+ );
69
+ root.querySelectorAll("[data-toggle]").forEach(
70
+ (b) =>
71
+ (b.onclick = () =>
72
+ run(async () => {
73
+ const p = data.policies.find((p) => p.id === b.dataset.toggle);
74
+ await api("backups/policies/" + p.id, "PUT", {
75
+ name: p.name,
76
+ target: p.target,
77
+ intervalMinutes: p.intervalMinutes,
78
+ keep: p.keep,
79
+ enabled: !p.enabled,
80
+ });
81
+ await load();
82
+ })),
83
+ );
84
+ }
85
+ async function run(fn) {
86
+ if (busy) return;
87
+ busy = true;
88
+ root.setAttribute("aria-busy", "true");
89
+ root.querySelectorAll("button").forEach((b) => (b.disabled = true));
90
+ try {
91
+ await fn();
92
+ } catch (e) {
93
+ if (!disposed) {
94
+ const notice = root.querySelector("#hostingNotice");
95
+ if (notice) notice.textContent = e.message;
96
+ else
97
+ root.textContent =
98
+ "Backups could not load. Reload the dashboard to try again.";
99
+ }
100
+ } finally {
101
+ busy = false;
102
+ if (!disposed) {
103
+ root.removeAttribute("aria-busy");
104
+ root.querySelectorAll("button").forEach((b) => (b.disabled = false));
105
+ }
106
+ }
107
+ }
108
+ run(load);
109
+ return () => {
110
+ disposed = true;
111
+ };
112
+ }
@@ -0,0 +1,87 @@
1
+ # Multinode — @x47base/pocketbase-addon
2
+
3
+ Included in the combined npm package; see the [installation guide](../README.md).
4
+
5
+ A small Go gateway for single-owner tenant routing and bounded public-read caching. Co-developed with AI. Included in the combined package; publication is a separate release step.
6
+
7
+ This is **not a PocketBase replication engine**. Multiple gateway processes may serve the same tenants, but every tenant routes to one authoritative PocketBase origin. Different tenant partitions may use different origins. Writes and private reads always use that origin. Gateway caches are independent and expire after at most 60 seconds (default two seconds); no broadcast is sent for each request.
8
+
9
+ ## Run locally
10
+
11
+ ```sh
12
+ npx pocketbase-addon gateway -config gateway.example.json -listen 127.0.0.1:8095
13
+ ```
14
+
15
+ The example sends requests for `127.0.0.1:8095` to the local commerce example on port 8093. Open the shop through port 8095. Health: `/healthz`; aggregate counters: `/metrics`. These endpoints describe the gateway, not database health. Stop with Ctrl+C.
16
+
17
+ ```sh
18
+ curl -i http://127.0.0.1:8095/api/commerce/stores/main
19
+ curl -i http://127.0.0.1:8095/api/commerce/stores/main
20
+ ```
21
+
22
+ The second response should include `X-Public-Cache: hit`. Stock, prices, customer accounts, orders, applications, admin routes and GraphQL are never cached. Only exact configured public store projection paths qualify, and the origin must explicitly respond `Cache-Control: public, max-age=...`. Credentials, cookies, range/conditional requests, CORS origins, query strings, Set-Cookie and non-Origin Vary responses bypass caching. PocketBase’s `Vary: Origin` is supported only for requests without an Origin header.
23
+
24
+ Install the combined package as described in [the root README](../README.md). `npx pocketbase-addon init` creates a loopback-only development config. For the example below, copy `node_modules/@x47base/pocketbase-addon/multinode/gateway.example.json` to your application directory and adjust its hosts and origins.
25
+
26
+ ## Docker
27
+
28
+ Build from the installed package source:
29
+
30
+ ```sh
31
+ docker build --target gateway -t your-registry/gateway:your-version node_modules/@x47base/pocketbase-addon
32
+ ```
33
+
34
+ Supply `/config/gateway.json` as a readable mount and put the gateway on a network that reaches the private origins. The image listens on port 8095. Do not expose the origin publicly. `allowHTTP` explicitly permits development or private-network HTTP; otherwise origins require HTTPS. Put TLS and upstream DDoS protection in front of public deployments. See [hosting deployment](../hosting/docs/DEPLOYMENT.md) for the commerce image contracts.
35
+
36
+ ## Bounds and consistency
37
+
38
+ - Unknown hosts fail with 421. The request Host never becomes an arbitrary upstream URL. Forwarded IP headers are rebuilt from the connection, not trusted from the caller. Private operator headers are stripped. The CLI caps simultaneous connections at 512; custom Go hosts must bound their listeners too.
39
+ - Default 128 admitted requests per process; overflow gets 503 with Retry-After. Default request body limit 2 MiB, checked before proxying when Content-Length is known and while streaming otherwise.
40
+ - Request headers have five seconds; total admitted request lifetime is two minutes, including streaming subscriptions (clients must reconnect). No buffered request bodies; cache objects are capped at 64 KiB, 128 entries by default. Cache misses for the same public key coalesce.
41
+ - Writes have no application-level retry or automatic origin failover. An unavailable owner causes 502, not a write to an inconsistent replica. Inspect uncertain mutation outcomes before retrying.
42
+ - Cache staleness is bounded by the shorter of configured TTL and origin max-age. Authenticated requests bypass it for read-after-write. Cache hits do not grant downstream caches a fresh TTL.
43
+ - A running origin remains a failure domain. Durable failover needs fenced ownership, a tested backup/restore process, and explicit RPO/RTO. This package does not provide consensus, replication, migrations or distributed locks.
44
+
45
+ ## Scaling architecture
46
+
47
+ ```mermaid
48
+ flowchart LR
49
+ Clients --> Edge[CDN / DDoS protection]
50
+ Edge --> G1[Gateway replicas]
51
+ G1 --> P1[PocketBase owner: partition A]
52
+ G1 --> P2[PocketBase owner: partition B]
53
+ P1 --> V[Vendure API replicas]
54
+ P2 --> V
55
+ V --> DB[(PostgreSQL)]
56
+ V --> Q[Redis / workers]
57
+ ```
58
+
59
+ Use a dealer as the commercial channel boundary and ordinary PocketBase records for teams and stores. Do not create a complete catalog or Vendure channel for every team shop. Use immutable/versioned published snapshots with a CDN for truly high-volume public browsing. Keep private workflow writes on their designated owner, and use partitioned client/server storage when a partition exceeds a single writer's capacity.
60
+
61
+ SQLite WAL requires shared memory on the same host and cannot serve as a network filesystem replication protocol ([SQLite WAL](https://www.sqlite.org/wal.html)). SQLite permits one writer per database at a time; many simultaneous writers warrant a client/server database ([SQLite usage guidance](https://www.sqlite.org/whentouse.html)). Vendure's [horizontal scaling guide](https://docs.vendure.io/current/core/deployment/horizontal-scaling/) describes API/worker scaling.
62
+
63
+ Millions of concurrent interactions are an unverified target, not a capability claim. Measure cache hit rate, origin request rate, p95/p99 latency, memory, SQLite lock wait, tenant skew, checkout correctness, queue lag, and recovery under owner loss. Gateways reduce repeat public reads; they do not remove database write limits or protect an already saturated network link.
64
+
65
+ ## Verification
66
+
67
+ ```sh
68
+ go test ./multinode
69
+ go test -race ./multinode
70
+ go vet ./multinode
71
+ ```
72
+
73
+ Tests cover tenant isolation, concurrent cache misses, expiration, credential/private response bypass, unknown hosts, body limits (including chunked reads), spoofed forwarding headers and no automatic mutation retries. They do not establish production capacity.
74
+
75
+ ## Store-bound domains and reload
76
+
77
+ Set a tenant's optional `store` to a commerce store slug. The gateway then permits only that store's public storefront/account/application route contract, rejects administration and cross-store paths, and preserves the configured host when proxying to the owner. The owner must also validate the live PocketBase domain lease via `commerce.GuardStoreHost` and resolve its public endpoints to that store. Keep the origin private; do not rely on a public Host header as authentication.
78
+
79
+ ```json
80
+ {"host":"team.example.com","store":"team-autumn","origin":"http://pocketbase:8090","publicPaths":["/api/commerce/stores/team-autumn","/api/commerce/stores/team-autumn/offers"]}
81
+ ```
82
+
83
+ Only the explicitly listed public paths can be cached; cookies/authenticated requests and writes bypass public caching. The metrics and health routes are operational endpoints and must be restricted at ingress if not intended publicly. A cached projection may outlive revocation for its configured cache TTL.
84
+
85
+ The CLI accepts `SIGHUP`: it validates a replacement configuration before swapping handlers; invalid configurations leave the previous routing active. Replacement handlers start with empty caches and release old idle connections. In-flight requests may finish on the previous handler. Keep concurrency allowances in mind during a reload; no global distributed rate-limit or ownership failover is added.
86
+
87
+ The combined package’s `hosting` command renders Docker/Kubernetes resources and exports verified domain routes. With a single-file Docker bind mount, recreate the service after atomically replacing the host file; otherwise the container may retain its old inode. Kubernetes configmap changes require a gateway rollout/reload after the mount updates.
@@ -0,0 +1 @@
1
+ {"allowHTTP":true,"cacheSeconds":2,"cacheEntries":128,"maxConcurrent":128,"tenants":[{"host":"127.0.0.1:8095","origin":"http://host.docker.internal:8093","publicPaths":["/api/commerce/stores/main"]}]}
@@ -0,0 +1 @@
1
+ {"allowHTTP":true,"cacheSeconds":2,"cacheEntries":128,"maxConcurrent":128,"maxBodyBytes":2097152,"tenants":[{"host":"127.0.0.1:8095","origin":"http://127.0.0.1:8093","publicPaths":["/api/commerce/stores/main","/api/commerce/stores/store","/api/commerce/stores/alpine-club"]}]}