@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.1

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 (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -2
  4. package/bin/snowglobe.mjs +71 -0
  5. package/lib/apps.mjs +97 -0
  6. package/lib/args.mjs +29 -0
  7. package/lib/certs.mjs +211 -0
  8. package/lib/commands/account.mjs +85 -0
  9. package/lib/commands/app.mjs +86 -0
  10. package/lib/commands/certs.mjs +36 -0
  11. package/lib/commands/check.mjs +37 -0
  12. package/lib/commands/down.mjs +40 -0
  13. package/lib/commands/env.mjs +146 -0
  14. package/lib/commands/invite.mjs +48 -0
  15. package/lib/commands/lexicons.mjs +94 -0
  16. package/lib/commands/ls.mjs +72 -0
  17. package/lib/commands/render.mjs +63 -0
  18. package/lib/commands/run.mjs +109 -0
  19. package/lib/commands/trust.mjs +45 -0
  20. package/lib/commands/up.mjs +301 -0
  21. package/lib/config.mjs +514 -0
  22. package/lib/env.mjs +526 -0
  23. package/lib/hosts.mjs +36 -0
  24. package/lib/prebuilt.mjs +56 -0
  25. package/lib/render.mjs +601 -0
  26. package/lib/settings.mjs +156 -0
  27. package/lib/state.mjs +109 -0
  28. package/lib/trust.mjs +288 -0
  29. package/lib/up.mjs +54 -0
  30. package/lib/xrpc.mjs +49 -0
  31. package/package.json +55 -4
  32. package/snowglobe.default.yml +54 -0
  33. package/stack/docker-compose.relay.yml +105 -0
  34. package/stack/docker-compose.test.yml +41 -0
  35. package/stack/docker-compose.yml +179 -0
  36. package/stack/https/app-names.cjs +31 -0
  37. package/stack/https/dns/Dockerfile +10 -0
  38. package/stack/https/dns/dns.sh +40 -0
  39. package/stack/https/nginx.conf.template +104 -0
  40. package/stack/https/proxy/Dockerfile +8 -0
  41. package/stack/https/proxy/server.mjs +213 -0
  42. package/stack/init/init.sh +118 -0
  43. package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
  44. package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
  45. package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
  46. package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
  47. package/stack/page/index.html +409 -0
  48. package/stack/page/snowglobe.conf +59 -0
  49. package/stack/pdsls/Dockerfile +27 -0
  50. package/stack/pdsls/network.patch +294 -0
  51. package/stack/pdsls/server.mjs +192 -0
  52. package/stack/relay/Dockerfile +30 -0
  53. package/stack/relay/allow-private-hosts.patch +55 -0
  54. package/stack/slingshot/Dockerfile +36 -0
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@openmeet/snowglobe",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0-rc.1",
4
+ "description": "Standalone, testable ATProto development network for local development and CI",
5
+ "type": "module",
6
+ "bin": {
7
+ "snowglobe": "bin/snowglobe.mjs"
8
+ },
9
+ "license": "MIT",
10
+ "author": "OpenMeet Team",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/OpenMeet-Team/snowglobe.git"
14
+ },
15
+ "homepage": "https://snowglobe.at",
16
+ "keywords": [
17
+ "atproto",
18
+ "bluesky",
19
+ "pds",
20
+ "jetstream",
21
+ "docker",
22
+ "development",
23
+ "testing"
24
+ ],
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "test:watch": "vitest"
28
+ },
29
+ "devDependencies": {
30
+ "@atproto/api": "^0.15.0",
31
+ "@types/ws": "^8.5.0",
32
+ "typescript": "^5.7.0",
33
+ "vitest": "^3.0.0",
34
+ "ws": "^8.18.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "dependencies": {
40
+ "@peculiar/asn1-schema": "2.11.0",
41
+ "@peculiar/asn1-x509": "2.11.0",
42
+ "@peculiar/x509": "2.2.0",
43
+ "reflect-metadata": "0.2.2",
44
+ "yaml": "^2.9.1"
45
+ },
46
+ "files": [
47
+ "bin/",
48
+ "lib/",
49
+ "stack/",
50
+ "!stack/**/*.test.mjs",
51
+ "snowglobe.default.yml",
52
+ ".env.example"
53
+ ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
@@ -0,0 +1,54 @@
1
+ # The default snowglobe: one current PDS, a PLC directory and Jetstream, all at https names, with
2
+ # Slingshot and PDSls (the record browser) beside them.
3
+ # Copy this file to snowglobe.yml (here, or in your app's repo) and run bin/snowglobe.mjs up.
4
+ # The commented sections below add more; docs/snowglobe.md says what each one does, and
5
+ # docs/examples/atmo.yml is the whole thing: three PDSes, a relay and a lexicon authority.
6
+
7
+ globe: snowglobe
8
+ domain: local.snowglobe.at # every name below is under it
9
+
10
+ pds:
11
+ - name: main # https://main.local.snowglobe.at
12
+ image: ghcr.io/bluesky-social/pds:0.4.5037 # a current release
13
+ bare_handles: true # its handles are <account>.local.snowglobe.at
14
+ # A second PDS, with handles <name>.other.local.snowglobe.at. With more than one PDS the
15
+ # relay turns on, so Jetstream sees every PDS's writes.
16
+ # - name: other
17
+ # image: ghcr.io/bluesky-social/pds:0.4.5009
18
+ #
19
+ # The permissioned-spaces alpha. Invites are required, and it is the PDS to name as the
20
+ # lexicon authority below. Its short name is pinned by digest.
21
+ # - name: alpha
22
+ # image: spaces-alpha
23
+ # invites: required
24
+
25
+ services: # these are the defaults, each true or false
26
+ jetstream: true
27
+ slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
28
+ pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
29
+ tap: false # sends an app the events of one collection (true, or a map; see docs/snowglobe.md)
30
+ # relay: auto # auto: on when there is more than one PDS
31
+
32
+ # Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
33
+ # lexicons:
34
+ # authority: alpha
35
+ # publish: lexicons # a folder next to this file; up publishes every lexicon in it
36
+
37
+ # A group host runs beside the PDSes, at https://gh.local.snowglobe.at.
38
+ # grouphosts:
39
+ # - name: gh
40
+ # kind: opensocial
41
+ # build: ../atproto-group-host # a folder with a Dockerfile, from this file's folder
42
+
43
+ accounts: # <name>@<pds>; up makes each one
44
+ - alice@main
45
+ - bob@main
46
+
47
+ # Your app, reached at https://myapp.app.local.snowglobe.at, with settings from the snowglobe.
48
+ # `snowglobe env` hands them over; docs/your-app.md has the names.
49
+ # apps:
50
+ # - name: myapp
51
+ # local: { port: 5173 }
52
+ # env:
53
+ # PUBLIC_ATPROTO_PLC_URL: ${services.plc.url}
54
+ # PUBLIC_ATPROTO_SIGNUP_PDS: ${pds.main.url}
@@ -0,0 +1,105 @@
1
+ # A local relay that crawls every PDS, so Jetstream and TAP see one merged stream, as they would
2
+ # in production. Read by lib/render.mjs when the relay is on; not meant to be run directly (see
3
+ # docker-compose.yml).
4
+ #
5
+ # PDSes --> relay (:2470) --> Jetstream (:6008)
6
+ # \--> TAP (:2480)
7
+ #
8
+ # The relay joins the PDSes' network namespace: it connects to each host at the
9
+ # localhost URL in its accounts' DID documents, and checks that each account's DID
10
+ # document names the host the event came from. TAP joins too, because a backfill
11
+ # fetches repos from those same URLs. Jetstream only reads the relay, so it stays
12
+ # on the bridge network and reaches the relay as pds:2470.
13
+ #
14
+ # A relay only adds localhost hosts through its admin API, so a PDS cannot register
15
+ # itself with requestCrawl. relay-init adds each PDS once the relay is up.
16
+
17
+ x-relay-admin-password: &relay-admin-password ${SG_RELAY_ADMIN_PASSWORD:?snowglobe up makes it}
18
+
19
+ services:
20
+ pds:
21
+ ports: !override
22
+ - "${SG_SPACES_PDS_PORT:-${DEVNET_SPACES_PDS_PORT:-3010}}:${SG_SPACES_PDS_PORT:-${DEVNET_SPACES_PDS_PORT:-3010}}"
23
+ - "3020:3020"
24
+ - "3030:3030"
25
+ - "${SG_RELAY_PORT:-${DEVNET_RELAY_PORT:-2470}}:2470"
26
+ - "${SG_TAP_PORT:-${DEVNET_TAP_PORT:-2480}}:2480"
27
+
28
+ relay:
29
+ # Upstream's relay with one patch, so it can crawl PDSes on localhost; see
30
+ # relay/Dockerfile. SG_RELAY_INDIGO_COMMIT picks the indigo commit (it needs
31
+ # a published relay-<commit> image, and the patch has to apply).
32
+ build:
33
+ context: ./stack/relay
34
+ args:
35
+ INDIGO_COMMIT: ${SG_RELAY_INDIGO_COMMIT:-${DEVNET_RELAY_INDIGO_COMMIT:-b2619d864df00a66455bd9f5f0c83c1ac13ad73d}}
36
+ image: atproto-devnet-relay:${SG_RELAY_INDIGO_COMMIT:-${DEVNET_RELAY_INDIGO_COMMIT:-b2619d864df00a66455bd9f5f0c83c1ac13ad73d}}
37
+ restart: unless-stopped
38
+ network_mode: service:pds
39
+ depends_on:
40
+ pds:
41
+ condition: service_healthy
42
+ environment:
43
+ RELAY_ADMIN_PASSWORD: *relay-admin-password
44
+ RELAY_PLC_HOST: http://plc:2582
45
+ # ws:// hosts; localhost hosts are accepted only through the admin API
46
+ RELAY_ALLOW_INSECURE_HOSTS: "true"
47
+ # the patched host check (relay/allow-private-hosts.patch)
48
+ RELAY_ALLOW_PRIVATE_HOSTS: "true"
49
+ # the PDS builds differ in how much sync metadata their events carry
50
+ RELAY_LENIENT_SYNC_VALIDATION: "true"
51
+ ENVIRONMENT: dev
52
+ volumes:
53
+ - relay-data:/data/relay
54
+ healthcheck:
55
+ test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2470/xrpc/_health"]
56
+ interval: 5s
57
+ timeout: 3s
58
+ retries: 10
59
+
60
+ relay-init:
61
+ image: alpine:3.20
62
+ network_mode: service:pds
63
+ depends_on:
64
+ relay:
65
+ condition: service_healthy
66
+ pds-regular:
67
+ condition: service_healthy
68
+ pds-prod:
69
+ condition: service_healthy
70
+ environment:
71
+ RELAY_ADMIN_PASSWORD: *relay-admin-password
72
+ RELAY_HOSTS: localhost:${SG_SPACES_PDS_PORT:-${DEVNET_SPACES_PDS_PORT:-3010}} localhost:3020 localhost:3030
73
+ entrypoint: /bin/sh
74
+ command:
75
+ - -c
76
+ - |
77
+ set -eu
78
+ apk add --no-cache curl >/dev/null 2>&1
79
+ for host in $$RELAY_HOSTS; do
80
+ echo "[relay-init] crawl $$host"
81
+ curl -sf -u "admin:$$RELAY_ADMIN_PASSWORD" -H "Content-Type: application/json" \
82
+ -d "{\"hostname\": \"$$host\"}" http://localhost:2470/admin/pds/requestCrawl
83
+ echo
84
+ done
85
+ restart: "no"
86
+
87
+ jetstream:
88
+ depends_on:
89
+ relay:
90
+ condition: service_healthy
91
+ environment:
92
+ JETSTREAM_WS_URL: ws://pds:2470/xrpc/com.atproto.sync.subscribeRepos
93
+
94
+ tap:
95
+ network_mode: service:pds
96
+ ports: !reset []
97
+ networks: !reset []
98
+ depends_on:
99
+ relay:
100
+ condition: service_healthy
101
+ environment:
102
+ TAP_RELAY_URL: http://localhost:2470
103
+
104
+ volumes:
105
+ relay-data:
@@ -0,0 +1,41 @@
1
+ # Postgres and MailDev, which the PLC server and the PDS's email verification need. Read by
2
+ # lib/render.mjs; not meant to be run directly (see docker-compose.yml).
3
+
4
+ services:
5
+ postgres:
6
+ image: postgres:16-alpine
7
+ # PLC restarts on its own (unless-stopped) but needs this; without it a
8
+ # Docker restart leaves postgres down and PLC crash-looping on DNS.
9
+ restart: unless-stopped
10
+ environment:
11
+ POSTGRES_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
12
+ POSTGRES_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
13
+ POSTGRES_DB: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
14
+ ports:
15
+ - "${SG_POSTGRES_PORT:-${DEVNET_POSTGRES_PORT:-5433}}:5432"
16
+ networks:
17
+ - atproto-devnet
18
+ healthcheck:
19
+ test: ["CMD-SHELL", "pg_isready -U ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}"]
20
+ interval: 3s
21
+ timeout: 3s
22
+ retries: 10
23
+
24
+ maildev:
25
+ image: maildev/maildev:latest
26
+ restart: unless-stopped
27
+ ports:
28
+ - "${SG_MAILDEV_WEB_PORT:-${DEVNET_MAILDEV_WEB_PORT:-1081}}:1080"
29
+ - "${SG_MAILDEV_SMTP_PORT:-${DEVNET_MAILDEV_SMTP_PORT:-1026}}:1025"
30
+ networks:
31
+ - atproto-devnet
32
+
33
+ # Override PLC/PDS to depend on test postgres/maildev
34
+ plc:
35
+ depends_on:
36
+ postgres:
37
+ condition: service_healthy
38
+
39
+ pds:
40
+ environment:
41
+ PDS_EMAIL_SMTP_URL: smtp://maildev:1025
@@ -0,0 +1,179 @@
1
+ # The service definitions snowglobe starts from (lib/render.mjs reads this file and
2
+ # docker-compose.test.yml and docker-compose.relay.yml, and writes the compose file a snowglobe
3
+ # runs). Not meant to be run directly: its paths are relative to the repository root, so use
4
+ # `bin/snowglobe.mjs up`.
5
+ #
6
+ # Needs from the rendered file: PostgreSQL (for the PLC server) and an SMTP server (for the
7
+ # PDS's email verification), which docker-compose.test.yml supplies.
8
+
9
+ services:
10
+ # ── DID Registry ─────────────────────────────────────────────────
11
+ plc:
12
+ image: itaru2622/bluesky-did-method-plc:latest
13
+ restart: unless-stopped
14
+ # PLC needs DB_CREDS_JSON as a JSON string, which can't be built from
15
+ # env var interpolation in compose. We wrap the entrypoint to construct
16
+ # it at runtime from the same credential vars used everywhere else. The container's own
17
+ # DEVNET_DB_* keep their names, though the settings are SG_DB_*: a project's overlay may set
18
+ # them on this service (openmeet-api's docker-compose-devnet.yml does).
19
+ entrypoint: >
20
+ sh -c "
21
+ export DB_CREDS_JSON=$$(printf '{\"username\":\"%s\",\"password\":\"%s\",\"host\":\"%s\",\"port\":\"%s\",\"database\":\"%s\"}' \"$$DEVNET_DB_USER\" \"$$DEVNET_DB_PASSWORD\" \"$$DEVNET_DB_HOST\" \"$$DEVNET_DB_PORT\" \"$$DEVNET_DB_NAME\")
22
+ export DB_MIGRATE_CREDS_JSON=$$DB_CREDS_JSON
23
+ exec node /app/packages/server/service/index.js
24
+ "
25
+ environment:
26
+ PORT: 2582
27
+ DEVNET_DB_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
28
+ DEVNET_DB_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
29
+ DEVNET_DB_HOST: ${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}
30
+ DEVNET_DB_PORT: ${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}
31
+ DEVNET_DB_NAME: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
32
+ DATABASE_URL: "postgres://${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}:${SG_DB_PASSWORD:?snowglobe up makes it}@${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}:${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}/${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}"
33
+ ENABLE_MIGRATIONS: "true"
34
+ LOG_ENABLED: "true"
35
+ LOG_LEVEL: info
36
+ NODE_ENV: development
37
+ ports:
38
+ - "${SG_PLC_PORT:-${DEVNET_PLC_PORT:-2582}}:2582"
39
+ networks:
40
+ - atproto-devnet
41
+ healthcheck:
42
+ test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2582/_health"]
43
+ interval: 5s
44
+ timeout: 3s
45
+ retries: 10
46
+
47
+ # ── Personal Data Server ─────────────────────────────────────────
48
+ pds:
49
+ # Any PDS build: a release (ghcr.io/bluesky-social/pds:<version>) or an
50
+ # upstream commit (ghcr.io/bluesky-social/atproto:pds-<commit sha>).
51
+ image: ${SG_PDS_IMAGE:-${DEVNET_PDS_IMAGE:-ghcr.io/bluesky-social/pds:0.4}}
52
+ # Release images run as root; monorepo images run as `node`, which cannot
53
+ # open the root-owned pds-data volume.
54
+ user: root
55
+ restart: unless-stopped
56
+ depends_on:
57
+ plc:
58
+ condition: service_healthy
59
+ environment:
60
+ PDS_PORT: 3000
61
+ PDS_HOSTNAME: ${SG_PDS_HOSTNAME:-${DEVNET_PDS_HOSTNAME:-devnet.test}}
62
+ PDS_DATA_DIRECTORY: /pds/data
63
+ PDS_BLOBSTORE_DISK_LOCATION: /pds/data/blocks
64
+ PDS_DID_PLC_URL: http://plc:2582
65
+ # The PDS forwards app.bsky.* reads, and any XRPC method it does not implement
66
+ # (com.atproto.space.* on a build without spaces, for one), to this AppView with
67
+ # a service-auth token for the caller. The default host does not resolve, so
68
+ # nothing on this network reaches Bluesky's production AppView. Point it at a
69
+ # real AppView only if your app needs reads proxied through the PDS; Bluesky's
70
+ # AppView never sees devnet accounts, since no relay outside devnet crawls them.
71
+ PDS_BSKY_APP_VIEW_URL: ${SG_APPVIEW_URL:-${DEVNET_APPVIEW_URL:-https://appview.invalid}}
72
+ PDS_BSKY_APP_VIEW_DID: ${SG_APPVIEW_DID:-${DEVNET_APPVIEW_DID:-did:example:invalid}}
73
+ PDS_REPORT_SERVICE_URL: ${SG_REPORT_SERVICE_URL:-${DEVNET_REPORT_SERVICE_URL:-https://moderator.invalid}}
74
+ PDS_REPORT_SERVICE_DID: ${SG_REPORT_SERVICE_DID:-${DEVNET_REPORT_SERVICE_DID:-did:example:invalid}}
75
+ PDS_CRAWLERS: ""
76
+ # PDS builds after upstream 1ff43e6e5 (2026-09-10) resolve DIDs through the
77
+ # SSRF-protected fetch, which refuses the local PLC's http:// URL.
78
+ PDS_DISABLE_SSRF_PROTECTION: "true"
79
+ PDS_INVITE_REQUIRED: "true"
80
+ PDS_ADMIN_PASSWORD: ${SG_PDS_ADMIN_PASSWORD:?snowglobe up makes it}
81
+ PDS_JWT_SECRET: ${SG_PDS_JWT_SECRET:?snowglobe up makes it}
82
+ PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${SG_PLC_ROTATION_KEY:?snowglobe up makes it}
83
+ PDS_EMAIL_SMTP_URL: ${SG_SMTP_URL:-${DEVNET_SMTP_URL:-smtp://maildev:1025}}
84
+ PDS_EMAIL_FROM_ADDRESS: noreply@devnet.test
85
+ PDS_HANDLE_DOMAINS: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
86
+ PDS_SERVICE_HANDLE_DOMAINS: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
87
+ LOG_ENABLED: "true"
88
+ NODE_ENV: development
89
+ volumes:
90
+ - pds-data:/pds/data
91
+ ports:
92
+ - "${SG_PDS_PORT:-${DEVNET_PDS_PORT:-3000}}:3000"
93
+ networks:
94
+ - atproto-devnet
95
+ healthcheck:
96
+ test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/xrpc/_health"]
97
+ interval: 5s
98
+ timeout: 3s
99
+ retries: 10
100
+
101
+ # ── Jetstream (JSON event stream from PDS firehose) ──────────────
102
+ jetstream:
103
+ image: ghcr.io/bluesky-social/jetstream:sha-05c89f0
104
+ restart: unless-stopped
105
+ depends_on:
106
+ pds:
107
+ condition: service_healthy
108
+ environment:
109
+ JETSTREAM_WS_URL: ws://pds:3000/xrpc/com.atproto.sync.subscribeRepos
110
+ JETSTREAM_DATA_DIR: /data
111
+ JETSTREAM_LIVENESS_TTL: 86400s
112
+ volumes:
113
+ - jetstream-data:/data
114
+ ports:
115
+ - "${SG_JETSTREAM_PORT:-${DEVNET_JETSTREAM_PORT:-6008}}:6008"
116
+ - "${SG_JETSTREAM_METRICS_PORT:-${DEVNET_JETSTREAM_METRICS_PORT:-6009}}:6009"
117
+ networks:
118
+ - atproto-devnet
119
+ healthcheck:
120
+ test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/6008'"]
121
+ interval: 5s
122
+ timeout: 3s
123
+ retries: 10
124
+
125
+ # ── TAP (repo sync + backfill) ───────────────────────────────────
126
+ tap:
127
+ # Pinned by digest (indigo revision 4f47add4, built 2026-03-11): the one the Statusphere flow was run against.
128
+ image: ghcr.io/bluesky-social/indigo/tap@sha256:5e20bfe416d29fcd215ed8bf99f10b2ab825a6de4e5599846dd33967ade2abeb
129
+ restart: unless-stopped
130
+ depends_on:
131
+ pds:
132
+ condition: service_healthy
133
+ environment:
134
+ TAP_RELAY_URL: http://pds:3000
135
+ TAP_PLC_URL: http://plc:2582
136
+ TAP_DATABASE_URL: sqlite:///data/tap.db
137
+ TAP_DISABLE_ACKS: "true"
138
+ TAP_LOG_LEVEL: info
139
+ volumes:
140
+ - tap-data:/data
141
+ ports:
142
+ - "${SG_TAP_PORT:-${DEVNET_TAP_PORT:-2480}}:2480"
143
+ networks:
144
+ - atproto-devnet
145
+ healthcheck:
146
+ test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2480/health"]
147
+ interval: 5s
148
+ timeout: 3s
149
+ retries: 10
150
+
151
+ # ── Init (one-shot setup) ────────────────────────────────────────
152
+ init:
153
+ image: alpine:3.20
154
+ depends_on:
155
+ pds:
156
+ condition: service_healthy
157
+ plc:
158
+ condition: service_healthy
159
+ volumes:
160
+ - ./stack/init:/scripts:ro
161
+ - ./data:/devnet-data
162
+ environment:
163
+ PDS_HOSTNAME: ${SG_PDS_HOSTNAME:-${DEVNET_PDS_HOSTNAME:-devnet.test}}
164
+ PDS_ADMIN_PASSWORD: ${SG_PDS_ADMIN_PASSWORD:?snowglobe up makes it}
165
+ HANDLE_DOMAIN: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
166
+ SEED_ACCOUNTS: ${SG_SEED_ACCOUNTS:-${DEVNET_SEED_ACCOUNTS:-true}}
167
+ entrypoint: /bin/sh
168
+ command: ["/scripts/init.sh"]
169
+ networks:
170
+ - atproto-devnet
171
+ restart: "no"
172
+
173
+ volumes:
174
+ pds-data:
175
+ jetstream-data:
176
+ tap-data:
177
+
178
+ networks:
179
+ atproto-devnet:
@@ -0,0 +1,31 @@
1
+ // Preloaded into each PDS (NODE_OPTIONS=--require, in the globe bin/snowglobe.mjs render writes), so a PDS can
2
+ // reach an app at https://<name>.app.<domain>: it fetches a confidential client's
3
+ // metadata and keys from there. Docker's DNS has no wildcard names, so every such name is
4
+ // looked up as nginx instead, which routes it by name (stack/https/nginx.conf.template). Only
5
+ // the address lookup changes: the request still carries the app's name, and TLS still
6
+ // checks the certificate for it against the snowglobe CA. Every other name resolves as usual.
7
+ //
8
+ // SG_RESOLVE_DOMAIN sets the domain whose names, itself included, go to nginx. Each PDS sets it
9
+ // to app.<domain>, where the apps are; a group host sets it to the globe's domain: it resolves its members' handles, which Docker's DNS does
10
+ // not know.
11
+ 'use strict';
12
+ const dns = require('dns');
13
+
14
+ // DEVNET_ was these settings' prefix until 2026-10-20; a container made before then still has it.
15
+ const DOMAIN = (process.env.SG_RESOLVE_DOMAIN || process.env.DEVNET_RESOLVE_DOMAIN || '').toLowerCase();
16
+ const PROXY = process.env.SG_APP_PROXY_HOST || process.env.DEVNET_APP_PROXY_HOST || 'nginx';
17
+ const isApp = (name) => {
18
+ if (typeof name !== 'string') return false;
19
+ const n = name.toLowerCase().replace(/\.$/, '');
20
+ return DOMAIN !== '' && (n === DOMAIN || n.endsWith(`.${DOMAIN}`));
21
+ };
22
+
23
+ const lookup = dns.lookup;
24
+ dns.lookup = function snowglobeLookup(hostname, ...rest) {
25
+ return lookup.call(this, isApp(hostname) ? PROXY : hostname, ...rest);
26
+ };
27
+
28
+ const lookupPromise = dns.promises.lookup;
29
+ dns.promises.lookup = function snowglobeLookupPromise(hostname, ...rest) {
30
+ return lookupPromise.call(this, isApp(hostname) ? PROXY : hostname, ...rest);
31
+ };
@@ -0,0 +1,10 @@
1
+ # The DNS a service in the compose network needs to check a handle: any name under
2
+ # SG_DOMAIN answers nginx's address. Docker's own DNS answers the service names first
3
+ # and asks this only for names it does not know; this refuses every name outside the domain,
4
+ # so nothing on the public internet resolves.
5
+ # On the machine the lab does this one name at a time (bin/snowglobe.mjs run); inside the
6
+ # network there is no such thing, and a handle's name is made when its account is.
7
+ FROM alpine:3.22
8
+ RUN apk add --no-cache dnsmasq
9
+ COPY dns.sh /dns.sh
10
+ ENTRYPOINT ["/bin/sh", "/dns.sh"]
@@ -0,0 +1,40 @@
1
+ #!/bin/sh
2
+ # dnsmasq for the https snowglobe. Every name under $SG_DOMAIN answers nginx's address
3
+ # (A only; other record types get no answer, and nothing under the domain is asked of any
4
+ # other server). It is the container's `dns:` server, which Docker's embedded DNS asks for
5
+ # any name it does not know itself, so service names keep working and nothing else is
6
+ # answered here.
7
+ # nginx's address changes when nginx is recreated, so it is looked up again every few
8
+ # seconds and dnsmasq restarted when it moved.
9
+ # When dnsmasq dies it is started again here, and this script does not exit: slingshot runs
10
+ # in this container's network namespace, and restarting this container leaves slingshot
11
+ # with no network at all until slingshot is recreated too.
12
+ set -eu
13
+ : "${SG_DOMAIN:?}"
14
+ ip_of_nginx() { getent hosts nginx | awk 'NR == 1 { print $1 }'; }
15
+ start() {
16
+ dnsmasq -k --no-resolv --no-hosts \
17
+ --local="/${SG_DOMAIN}/" --address="/${SG_DOMAIN}/$1" &
18
+ pid=$!
19
+ }
20
+ trap 'kill "${pid:-0}" 2>/dev/null; exit 0' TERM INT
21
+ # With no address, --address would answer NXDOMAIN for the whole domain, so wait for one.
22
+ ip=""
23
+ until [ -n "${ip}" ]; do
24
+ ip=$(ip_of_nginx || true)
25
+ [ -n "${ip}" ] || sleep 1
26
+ done
27
+ start "${ip}"
28
+ while sleep 5; do
29
+ now=$(ip_of_nginx || true)
30
+ if [ -n "${now}" ] && [ "${now}" != "${ip}" ]; then
31
+ kill "${pid}" 2>/dev/null || true
32
+ wait "${pid}" 2>/dev/null || true
33
+ ip="${now}"
34
+ start "${ip}"
35
+ elif ! kill -0 "${pid}" 2>/dev/null; then
36
+ echo "dns.sh: dnsmasq exited; starting it again" >&2
37
+ wait "${pid}" 2>/dev/null || true
38
+ start "${ip}"
39
+ fi
40
+ done
@@ -0,0 +1,104 @@
1
+ # nginx for the https snowglobe (the globe's compose.yml, bin/snowglobe.mjs render). One leaf
2
+ # certificate names every host below; data/https/ca.crt is the CA that signed it (bin/snowglobe.mjs certs).
3
+ # A template: at start the image writes it to conf.d/default.conf with the SG_DOMAIN
4
+ # setting filled in, so this is the http block's whole server list. The comments below
5
+ # name its default, local.snowglobe.at.
6
+
7
+ # Docker's embedded DNS, re-asked every 10 s, so a recreated container's new
8
+ # address is picked up without reloading nginx.
9
+ resolver 127.0.0.11 valid=10s ipv6=off;
10
+
11
+ map $http_upgrade $connection_upgrade {
12
+ default upgrade;
13
+ '' close;
14
+ }
15
+
16
+ ssl_certificate /etc/nginx/https/leaf.crt;
17
+ ssl_certificate_key /etc/nginx/https/leaf.key;
18
+ ssl_protocols TLSv1.2 TLSv1.3;
19
+
20
+ proxy_http_version 1.1;
21
+ proxy_set_header Host $host;
22
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23
+ proxy_set_header X-Forwarded-Proto https;
24
+ proxy_set_header Upgrade $http_upgrade;
25
+ proxy_set_header Connection $connection_upgrade;
26
+ # Firehose and event-stream websockets stay open.
27
+ proxy_read_timeout 1h;
28
+ proxy_send_timeout 1h;
29
+ # Blob uploads.
30
+ client_max_body_size 100m;
31
+
32
+ # A name this file does not list gets no certificate at all.
33
+ server {
34
+ listen 443 ssl default_server;
35
+ ssl_reject_handshake on;
36
+ }
37
+
38
+ # Each PDS and its handles, and the relay, the feed, Slingshot and PDSls, at their names under
39
+ # the domain: rendered from snowglobe.yml by bin/snowglobe.mjs render into the globe's nginx/
40
+ # folder (data/globe/nginx/globe.conf), mounted at /etc/nginx/globe/. Each PDS is its own
41
+ # service, pds-<name>:3000. A PDS answers /.well-known/atproto-did by Host, which the http block
42
+ # passes through as sent. An app's handle ending (below) wins over a PDS's *.<domain> as the
43
+ # longer wildcard.
44
+ include /etc/nginx/globe/*.conf;
45
+
46
+ # The directory at an https name under the domain, as a hosted globe has it (plc.<globe>), so an
47
+ # app's browser code, which cannot use plain http or the host's ports, reaches it too.
48
+ server {
49
+ listen 443 ssl;
50
+ server_name plc.${SG_DOMAIN};
51
+ location / {
52
+ set $plc http://plc:2582;
53
+ proxy_pass $plc;
54
+ }
55
+ }
56
+
57
+ # The network's own page at the bare name, as a globe serves it at <globe>.snowglobe.at: the same
58
+ # page/index.html, with this stack's relays (page/snowglobe.conf) and a snowglobe.json naming its
59
+ # services, which bin/snowglobe.mjs up and snowglobe app write.
60
+ server {
61
+ listen 443 ssl;
62
+ server_name ${SG_DOMAIN};
63
+ server_tokens off;
64
+ root /srv/page;
65
+ include /etc/nginx/page/snowglobe.conf;
66
+ }
67
+
68
+ # This stack's PLC, standing in for the real directory.
69
+ server {
70
+ listen 443 ssl;
71
+ server_name plc.directory;
72
+ location / {
73
+ set $plc http://plc:2582;
74
+ proxy_pass $plc;
75
+ }
76
+ }
77
+
78
+ # Apps on this machine: https://<name>.app.${SG_DOMAIN} goes to the port that
79
+ # bin/snowglobe.mjs app recorded for <name> (data/https/apps). It writes, into
80
+ # data/https/nginx/ (mounted at /etc/nginx/snowglobe/), one upstream per app, naming
81
+ # host.docker.internal, and one map line per name. An upstream's name is resolved
82
+ # once, when nginx loads it, through the container's hosts file (extra_hosts), which
83
+ # Docker's DNS does not serve. Neither file has to exist. An app that claimed a handle
84
+ # ending (bin/snowglobe.mjs app <name> <port> <ending>) also gets a server there for
85
+ # *.<ending>.local.snowglobe.at, which wins over a PDS's *.local.snowglobe.at as the
86
+ # longer wildcard.
87
+ include /etc/nginx/snowglobe/*.conf;
88
+
89
+ map $host $snowglobe_app {
90
+ default "";
91
+ include /etc/nginx/snowglobe/*.map;
92
+ }
93
+
94
+ # A name no app registered gets a 404 from nginx itself and reaches nothing.
95
+ server {
96
+ listen 443 ssl;
97
+ server_name *.app.${SG_DOMAIN};
98
+ if ($snowglobe_app = "") {
99
+ return 404 "No app is registered at $host (bin/snowglobe.mjs app).\n";
100
+ }
101
+ location / {
102
+ proxy_pass http://$snowglobe_app;
103
+ }
104
+ }
@@ -0,0 +1,8 @@
1
+ # The snowglobe's way in for a browser on another computer (server.mjs): a proxy to nginx and
2
+ # the PAC file that points at it, on one port. No dependencies, Node's own net and http.
3
+ FROM node:22-alpine
4
+ WORKDIR /app
5
+ COPY server.mjs ./
6
+ USER node
7
+ EXPOSE 1080
8
+ CMD ["node", "server.mjs"]