@openmeet/snowglobe 0.3.0-rc.0 → 0.3.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.
package/.env.example CHANGED
@@ -1,7 +1,7 @@
1
1
  # Database credentials — single source of truth for PLC and test postgres.
2
2
  # Consumer overrides these to use their own postgres instance.
3
3
  SG_DB_USER=postgres
4
- SG_DB_PASSWORD=postgres
4
+ # SG_DB_PASSWORD= (unset: snowglobe up makes a random one, in secrets.env in the snowglobe's state folder)
5
5
  SG_DB_HOST=postgres
6
6
  SG_DB_PORT=5432
7
7
  SG_DB_NAME=plc
package/README.md CHANGED
@@ -80,14 +80,19 @@ keeps the https names, and they are what make an app run unchanged.
80
80
  ## Quick start
81
81
 
82
82
  ```bash
83
- git clone https://github.com/OpenMeet-Team/snowglobe.git
84
- cd snowglobe
85
- npm install
86
- npm link # puts the snowglobe command on your PATH
87
- cp snowglobe.default.yml snowglobe.yml # the file says what your snowglobe runs; edit it freely
88
- snowglobe up # the first run pulls and builds images, which takes a while
83
+ npm install -g @openmeet/snowglobe # puts the snowglobe command on your PATH
84
+ snowglobe up --default # the first run pulls and builds images, which takes a while
89
85
  ```
90
86
 
87
+ To change what it runs, copy the default file into your folder, give it a name of its own on the
88
+ `globe:` line (`snowglobe` is the default file's, and a snowglobe belongs to the file that first
89
+ brought it up), edit it, and run `snowglobe up`:
90
+ `cp "$(npm root -g)/@openmeet/snowglobe/snowglobe.default.yml" snowglobe.yml`.
91
+ To keep the command with one project instead, run `npm install --save-dev @openmeet/snowglobe`, then
92
+ `npx snowglobe up --default` for the default, or `npx snowglobe up` once the folder has a
93
+ `snowglobe.yml` (copy it from `node_modules/@openmeet/snowglobe/snowglobe.default.yml`). Either
94
+ command also fits in a `package.json` script.
95
+
91
96
  Now try it. `snowglobe run` runs a command that can reach the snowglobe by name, and `--cacert`
92
97
  gives curl the snowglobe's certificate authority (CA), so it trusts the snowglobe's https:
93
98
 
package/bin/snowglobe.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // snowglobe: run the globe a snowglobe.yml describes.
3
3
  //
4
4
  // check checks a snowglobe.yml
5
+ // ls lists the snowglobes on this machine
5
6
  // render writes the https globe it describes into its state folder
6
7
  // certs makes your CA, once, and the leaf nginx serves
7
8
  // trust puts your CA in the system and browser trust stores (--remove takes it out)
@@ -10,8 +11,8 @@
10
11
  // invite mints an invite code on one of its PDSes
11
12
  // lexicons publishes lexicons into its lexicon authority
12
13
  // app routes https://<name>.app.<domain> to a port on this machine
13
- // up brings the globe up, as the compose project named for its file
14
- // down stops it
14
+ // up brings the globe up, as the compose project named for its file (or <name>, -f <file>)
15
+ // down stops it (the same ways)
15
16
  // run runs a command with the app's settings, and plc.directory pointed at the snowglobe
16
17
  //
17
18
  // A snowglobe is the snowglobe.yml in the folder you are in or the nearest one above (else
@@ -30,6 +31,7 @@ import { Refused, UsageError, adoptOldNames } from '../lib/env.mjs';
30
31
  // Loaded when it runs, so check and render do without the certificate library.
31
32
  const COMMANDS = {
32
33
  check: () => import('../lib/commands/check.mjs'),
34
+ ls: () => import('../lib/commands/ls.mjs'),
33
35
  render: () => import('../lib/commands/render.mjs'),
34
36
  certs: () => import('../lib/commands/certs.mjs'),
35
37
  trust: () => import('../lib/commands/trust.mjs'),
package/lib/apps.mjs CHANGED
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
15
15
  import { join } from 'node:path';
16
16
  import { ROOT, globeFacts, paths } from './env.mjs';
17
17
 
18
- const HEADER = '# Written by bin/snowglobe.mjs app from https/apps; rerun it instead of editing.\n';
18
+ const HEADER = '# Written by snowglobe app from https/apps; rerun it instead of editing.\n';
19
19
 
20
20
  /**
21
21
  * The first saved route whose ending is `app`, or null. app.<domain> is where the apps are, so an
@@ -25,7 +25,7 @@ export const appEnding = (apps) => apps.find((a) => a.ending === 'app') ?? null;
25
25
 
26
26
  export const appEndingMessage = (file, a) =>
27
27
  `${file}: the line "${formatApps([a]).trim()}" claims the ending app, which is where the apps are (${a.name}.app.<domain>); ` +
28
- `remove the route (bin/snowglobe.mjs app --remove ${a.name}) or give it another ending`;
28
+ `remove the route (snowglobe app --remove ${a.name}) or give it another ending`;
29
29
 
30
30
  /** The routes in an apps file's text: [{ name, port, ending }], ending null when none. */
31
31
  export function parseApps(text) {
@@ -85,7 +85,7 @@ export const writeApps = (p, apps) => put(p.apps, formatApps(apps));
85
85
  export function renderRoutes({ root = ROOT, state, env = process.env } = {}) {
86
86
  const p = paths(root, state);
87
87
  const pageJson = join(p.globe, 'page.json');
88
- if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run bin/snowglobe.mjs up, which renders it`);
88
+ if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run snowglobe up, which renders it`);
89
89
  const { domain } = globeFacts({ root, state, env });
90
90
  const apps = readApps(p);
91
91
  const bad = appEnding(apps);
@@ -1,37 +1,40 @@
1
- // bin/snowglobe.mjs down [-v]
1
+ // bin/snowglobe.mjs down [<file> | <name>] [-f <file>] [-v]
2
2
  //
3
3
  // down stops the compose project of the snowglobe in this folder (or above, see lib/state.mjs;
4
- // SG_PROJECT if set), on the globe up last rendered (globe/ in its state folder), and a service an
4
+ // SG_PROJECT if set), or of the one it is pointed at: a file (<file> or -f <file>), or a name
5
+ // from snowglobe ls, which works even when the file that owned it is gone, on the globe up last rendered (globe/ in its state folder), and a service an
5
6
  // earlier render ran and this one does not with it.
6
7
  // -v also drop the project's volumes: every account, DID and record is gone. The PLC's
7
8
  // database is a named volume, so without -v the DIDs survive with the PDSes.
8
9
  //
9
10
  // The state folder is never deleted. After -v, the next up starts over: the base init reseeds
10
11
  // alice and bob and rewrites accounts.env from scratch, dropping the logins the tools wrote there
11
- // (their accounts are gone too), and the lexicon authority is made again. The CA, the leaf and
12
+ // (their accounts are gone too), and the lexicon authority is made again. The fixed secrets an
13
+ // older snowglobe kept for its data (secrets.env) are dropped with it, so up makes random ones. The CA, the leaf and
12
14
  // the app routes stay either way. The project is guarded as up guards it.
13
15
 
14
- import { ROOT, dc, guardProject } from '../env.mjs';
15
- import { session } from '../state.mjs';
16
+ import { ROOT, dc, dropLegacySecrets, guardProject } from '../env.mjs';
17
+ import { byName, pointedAt, session } from '../state.mjs';
16
18
 
17
19
  export const command = {
18
20
  name: 'down',
19
- usage: 'down [-v]',
20
- options: { volumes: { type: 'boolean', short: 'v' } },
21
- positionals: [0, 0],
22
- accepts: ({ tokens }) => tokens.length <= 1,
21
+ usage: 'down [<file> | <name>] [-f <file>] [-v]',
22
+ options: { volumes: { type: 'boolean', short: 'v' }, file: { type: 'string', short: 'f' } },
23
+ positionals: [0, 1],
23
24
  };
24
25
 
25
- export function run({ values }) {
26
- return down({ volumes: values.volumes });
26
+ export function run({ values, positionals: [word] }) {
27
+ return down({ volumes: values.volumes, ...pointedAt({ file: values.file, word }) });
27
28
  }
28
29
 
29
- export function down({ volumes = false, file, root = ROOT, env = process.env } = {}) {
30
+ export function down({ volumes = false, file, name, root = ROOT, env = process.env } = {}) {
30
31
  // The shared devnet is refused before anything is looked up, even by a file that is not there.
31
32
  if (env.SG_PROJECT) guardProject(env);
32
- const found = session({ file, env });
33
+ const found = name ? byName(name, { env }) : session({ file, env });
33
34
  const project = guardProject(env, found.name);
34
35
  const res = dc(project, ['down', '--remove-orphans', ...(volumes ? ['-v'] : [])], { root, state: found.state, env });
35
36
  if (res.error) throw res.error;
37
+ // The data the fixed secrets were kept for is gone only once compose says it removed it.
38
+ if (volumes && res.status === 0) dropLegacySecrets({ root, state: found.state });
36
39
  return res.status ?? 1;
37
40
  }
@@ -12,7 +12,7 @@
12
12
  // lib/settings.mjs has the names.
13
13
 
14
14
  import { existsSync } from 'node:fs';
15
- import { ROOT, UsageError, envGet, paths, resolveDomain, setting } from '../env.mjs';
15
+ import { ROOT, UsageError, envGet, paths, resolveBind, resolveDomain, setting } from '../env.mjs';
16
16
  import { session } from '../state.mjs';
17
17
  import { isSecret, lookup, parseValue } from '../settings.mjs';
18
18
  import { accountPrefixes } from '../up.mjs';
@@ -51,7 +51,8 @@ function load({ file, root = ROOT, env = process.env } = {}) {
51
51
  if (name === 'ca.file') return existsSync(p.ca) ? p.ca : undefined;
52
52
  if (name === 'accounts.file') return existsSync(p.accounts) ? p.accounts : undefined;
53
53
  // The mail page's port is a setting, not recorded in the state; the rendered globe says up has run.
54
- if (name === 'services.mail.url') return !existsSync(p.compose) ? undefined : `http://127.0.0.1:${setting('SG_MAILDEV_WEB_PORT', { root, env }) || '1081'}`;
54
+ if (name === 'services.mail.url') return !existsSync(p.compose) ? undefined : `http://${resolveBind({ root, state: found.state, env })}:${setting('SG_MAILDEV_WEB_PORT', { root, env }) || '1081'}`;
55
+ if (name === 'services.tap.url') return !existsSync(p.compose) ? undefined : `http://${resolveBind({ root, state: found.state, env })}:${setting('SG_TAP_PORT', { root, env }) || '2480'}`;
55
56
  if (name === 'lexicons.authority.did') return kept('LEX_AUTHORITY_DID');
56
57
  if (head === 'accounts') return kept(`${prefixes.get(rest[0])}_${rest[1].toUpperCase()}`);
57
58
  return undefined;
@@ -0,0 +1,72 @@
1
+ // bin/snowglobe.mjs ls
2
+ //
3
+ // ls lists the snowglobes on this machine: every folder in the state root (lib/env.mjs stateRoot;
4
+ // ca/, the one CA, is not one) with its name, the domain it was rendered for, whether its compose
5
+ // project is running, and the file that owns it. Take the name to `up`, `down`, or a file to -f.
6
+ // "up" is a project with a container running (the one-shot init and relay-init have exited by
7
+ // then), "down" is none, or no project yet; "unknown" is what it says when Docker did not answer.
8
+ // Reads only; nothing is started.
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { ROOT, envGet, paths, stateRoot } from '../env.mjs';
14
+ import { ownerOf } from '../state.mjs';
15
+
16
+ export const command = {
17
+ name: 'ls',
18
+ usage: 'ls',
19
+ options: {},
20
+ positionals: [0, 0],
21
+ };
22
+
23
+ /** Compose's own status for each project, "running(5)" or "exited(2)" or "exited(1), running(10)", by name; null when Docker did not answer. */
24
+ export function projectStatuses() {
25
+ const res = spawnSync('docker', ['compose', 'ls', '-a', '--format', 'json'], { encoding: 'utf8' });
26
+ if (res.error || res.status !== 0) return null;
27
+ try {
28
+ return new Map(JSON.parse(res.stdout || '[]').map((p) => [p.Name, p.Status ?? '']));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // What a compose status means for the table.
35
+ export function stateWord(status) {
36
+ return /running\(\d+\)/.test(status ?? '') ? 'up' : 'down';
37
+ }
38
+
39
+ /**
40
+ * The snowglobes in the state root: [{ name, domain, state, file }], by name. `statuses` is
41
+ * projectStatuses()'s answer, null for "unknown". A file that is gone is shown as "(gone)".
42
+ */
43
+ export function list({ root = ROOT, env = process.env, statuses = projectStatuses() } = {}) {
44
+ const base = stateRoot(env);
45
+ if (!existsSync(base)) return [];
46
+ return readdirSync(base, { withFileTypes: true })
47
+ .filter((e) => e.isDirectory() && e.name !== 'ca')
48
+ .map((e) => {
49
+ const state = join(base, e.name);
50
+ const owner = ownerOf(state);
51
+ return {
52
+ name: e.name,
53
+ domain: envGet('SG_DOMAIN', paths(root, state).globeEnv) || '-',
54
+ state: statuses === null ? 'unknown' : stateWord(statuses.get(env.SG_PROJECT || e.name)),
55
+ file: owner ? (existsSync(owner) ? owner : `${owner} (gone)`) : '-',
56
+ };
57
+ })
58
+ .sort((a, b) => a.name.localeCompare(b.name));
59
+ }
60
+
61
+ export function run() {
62
+ const rows = list();
63
+ if (!rows.length) {
64
+ console.log(`No snowglobes in ${stateRoot()}. snowglobe up in a folder with a snowglobe.yml makes one.`);
65
+ return 0;
66
+ }
67
+ const head = { name: 'NAME', domain: 'DOMAIN', state: 'STATE', file: 'FILE' };
68
+ const cols = Object.keys(head);
69
+ const width = Object.fromEntries(cols.map((c) => [c, Math.max(head[c].length, ...rows.map((r) => r[c].length))]));
70
+ for (const r of [head, ...rows]) console.log(cols.map((c, i) => (i === cols.length - 1 ? r[c] : r[c].padEnd(width[c]))).join(' '));
71
+ return 0;
72
+ }
@@ -76,7 +76,7 @@ export function runUnder(cmd, { root = ROOT, env = process.env, state = session(
76
76
  if (!bwrap) throw new Error('bwrap (bubblewrap) is not installed, or not on PATH');
77
77
  // Refuses a SG_DOMAIN the render is not for, and a SG_BIND that is not loopback.
78
78
  const { domain } = resolveDomain({ root, state, env });
79
- const bind = resolveBind({ root, env });
79
+ const bind = resolveBind({ root, state, env });
80
80
 
81
81
  const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8') : '');
82
82
  const names = runNames({ leafNames: read(p.leafNames), handles: read(p.names), apps: readApps(p), domain, extra: env.HTTPS_RUN_EXTRA_NAMES });
@@ -1,4 +1,4 @@
1
- // bin/snowglobe.mjs up [<file>]
1
+ // bin/snowglobe.mjs up [<file> | <name>] [-f <file>] [--default]
2
2
  //
3
3
  // up brings up the globe a snowglobe.yml describes, as the compose project named for the file's
4
4
  // `globe:` (or SG_PROJECT). Safe to rerun: each step keeps what an earlier run made, and a rerun
@@ -10,7 +10,8 @@
10
10
  // means that folder. A checkout that still holds a globe in its own data/ is refused (nothing
11
11
  // started, nothing moved) with the command that moves it.
12
12
  //
13
- // 1. render: data/globe/ from <file>, else SG_FILE, else the nearest snowglobe.yml here or above
13
+ // 1. render: data/globe/ from <file> (or -f <file>), or the file that owns the snowglobe called
14
+ // <name> (snowglobe ls lists them), else SG_FILE, else the nearest snowglobe.yml here or above
14
15
  // (none: up refuses; `up --default` runs the default globe, snowglobe.default.yml: one PDS,
15
16
  // a PLC directory and Jetstream).
16
17
  // SG_DOMAIN, from the environment or .env, replaces the file's domain.
@@ -34,10 +35,10 @@
34
35
  //
35
36
  // A rerun finds the accounts and leaves unchanged lexicons alone. Anything else an app makes
36
37
  // (more accounts, invite codes) is its own, with account, invite and lexicons. Nothing secret is
37
- // printed. The tool ports come from the usual variables
38
- // (SG_PLC_PORT, SG_JETSTREAM_PORT, SG_JETSTREAM_METRICS_PORT,
39
- // SG_MAILDEV_WEB_PORT, SG_MAILDEV_SMTP_PORT, SG_PROXY_PORT), in the environment or
40
- // .env; pass the same ones on every run. devnet-spaces, the shared devnet, is refused (exit 3) as
38
+ // printed. The tool ports (SG_PLC_PORT, SG_JETSTREAM_PORT, SG_JETSTREAM_METRICS_PORT,
39
+ // SG_MAILDEV_WEB_PORT, SG_MAILDEV_SMTP_PORT, SG_PROXY_PORT) are published on the snowglobe's own
40
+ // loopback address (SG_BIND, else the one chosen at its first up and kept in its state folder), so
41
+ // two snowglobes need no port settings; a port variable moves one, in the environment or .env. devnet-spaces, the shared devnet, is refused (exit 3) as
41
42
  // the project unless SG_RESET_OK=1, before anything else is done.
42
43
 
43
44
  import { createHash } from 'node:crypto';
@@ -45,8 +46,10 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
45
46
  import { dirname, join, resolve } from 'node:path';
46
47
  import { setTimeout as sleep } from 'node:timers/promises';
47
48
  import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
48
- import { ROOT, addName, dc, ensureAdminPassword, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
49
- import { claimState, session } from '../state.mjs';
49
+ import { loadGlobe } from '../config.mjs';
50
+ import { ROOT, addName, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
51
+ import { prebuilt } from '../prebuilt.mjs';
52
+ import { byName, claimState, pointedAt, session } from '../state.mjs';
50
53
  import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
51
54
  import { account } from './account.mjs';
52
55
  import { lexicons } from './lexicons.mjs';
@@ -55,12 +58,12 @@ import { writeGlobe } from './render.mjs';
55
58
 
56
59
  export const command = {
57
60
  name: 'up',
58
- usage: 'up [<file> | --default]',
61
+ usage: 'up [<file> | <name>] [-f <file>] [--default]',
59
62
  // --default is the only way up runs the repo's default snowglobe; with no file and no
60
63
  // snowglobe.yml in this folder or above, up refuses.
61
- options: { default: { type: 'boolean' } },
64
+ options: { default: { type: 'boolean' }, file: { type: 'string', short: 'f' } },
62
65
  positionals: [0, 1],
63
- accepts: ({ values, positionals }) => !(values.default && positionals.length),
66
+ accepts: ({ values, positionals }) => !(values.default && (positionals.length || values.file)),
64
67
  };
65
68
 
66
69
  // How long a one-shot (init, relay-init) may take, and nginx to answer again after a restart.
@@ -80,8 +83,23 @@ async function waitServing(domain, { root, state, env }) {
80
83
  }
81
84
  }
82
85
 
83
- export async function run({ values, positionals: [file] }) {
84
- await up({ file: values.default ? join(ROOT, 'snowglobe.default.yml') : file, log: (m) => console.error(m) });
86
+ export async function run({ values, positionals: [word] }) {
87
+ let file;
88
+ if (values.default) file = join(ROOT, 'snowglobe.default.yml');
89
+ else {
90
+ const at = pointedAt({ file: values.file, word });
91
+ file = at.file;
92
+ if (at.name) {
93
+ // A snowglobe by its name is brought up from the file that owns it.
94
+ file = byName(at.name).path;
95
+ if (!file) throw new Error(`the file that owned the snowglobe ${at.name} is gone; bring it up from a file: snowglobe up -f <file>`);
96
+ // The file may have been renamed since (globe: changed): up would then start the new name.
97
+ const { globe } = loadGlobe(file);
98
+ if (globe && globe.globe !== at.name)
99
+ throw new Error(`${file} says globe: ${globe.globe} now, not ${at.name}; put globe: ${at.name} back to bring ${at.name} up, or snowglobe up ${file} to start ${globe.globe} as a new snowglobe`);
100
+ }
101
+ }
102
+ await up({ file, log: (m) => console.error(m) });
85
103
  }
86
104
 
87
105
  export async function up({ file, root = ROOT, env = process.env, log = () => {} }) {
@@ -98,13 +116,16 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
98
116
  // Both are read as compose reads them, and refused here when they are not a domain and a
99
117
  // loopback address.
100
118
  const { set } = resolveDomain({ root, state, env, rendering: true });
101
- resolveBind({ root, env });
119
+ resolveBind({ root, state, env });
102
120
 
103
121
  const { path, globe } = checkedGlobe(found.path, { domain: set || undefined });
104
122
  if (!globe) throw new Error(`${path} has problems (above); ${p.globe} is as it was`);
105
123
  // From here on the file's globe is the one this state folder is for.
106
124
  const took = claimState(found);
107
125
  if (took) log(took);
126
+ // Its own loopback address for every published port, chosen once, so a second snowglobe starts beside this one.
127
+ const bind = await chooseBind({ root, state, project, env });
128
+ if (bind !== '127.0.0.1') log(`${found.name} publishes its ports on ${bind}`);
108
129
  // Checked before the render changes, so a refused file leaves the running globe's render.
109
130
  const kept = readApps(p);
110
131
  const badEnding = appEnding(kept);
@@ -139,7 +160,10 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
139
160
 
140
161
  const facts = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
141
162
  // compose fills the file's ${SG_LEXICON_AUTHORITY_DID} from this.
142
- const composeEnv = { ...env, SG_PDS_ADMIN_PASSWORD: ensureAdminPassword({ root, state, env }) };
163
+ const secrets = ensureSecrets({ root, state, env, hasData: () => projectHasData(project, env) });
164
+ if (secrets.kept.length)
165
+ log(`${found.name} has data made before its secrets were random, so it keeps the fixed ${secrets.kept.join(', ')}. snowglobe down -v, then up, starts it over with random ones (every account is lost).`);
166
+ const composeEnv = { ...env, ...secrets.values };
143
167
  const compose = (args, opts = {}) => {
144
168
  const res = dc(project, args, { root, state, env: composeEnv, ...opts });
145
169
  if (res.error) throw res.error;
@@ -189,8 +213,22 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
189
213
  // A group host built from a folder is the folder as it is now; Docker's cache makes an
190
214
  // unchanged image quick.
191
215
  const builds = facts('SG_BUILDS');
192
- log(`Building images${builds.length ? ` (${builds.join(' ')})` : ''}`);
193
- compose(['build', '--quiet']);
216
+ // The images snowglobe publishes are pulled where the registry has the build of these very files
217
+ // (lib/prebuilt.mjs); only the rest are built. Compose fills in the ${...} defaults (an image's
218
+ // tag follows its commit); if it cannot say, everything is built, as before.
219
+ let toBuild = [];
220
+ try {
221
+ const services = JSON.parse(compose(['config', '--format', 'json'], { stdio: 'pipe', encoding: 'utf8' }).stdout).services;
222
+ const have = prebuilt({ root, services, env, log });
223
+ toBuild = Object.keys(services).filter((s) => services[s].build && !have.includes(s));
224
+ } catch {
225
+ toBuild = [];
226
+ compose(['build', '--quiet']);
227
+ }
228
+ if (toBuild.length) {
229
+ log(`Building images${builds.length ? ` (${builds.join(' ')})` : ''}`);
230
+ compose(['build', '--quiet', ...toBuild]);
231
+ }
194
232
 
195
233
  // A rerun starts the PDSes with the authority they already have, so pass 1 changes nothing.
196
234
  composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
@@ -212,6 +250,12 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
212
250
  if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
213
251
  composeEnv.SG_LEXICON_AUTHORITY_DID = did;
214
252
  log(`Pass 2: up with the lexicon authority ${did}`);
253
+ // Pass 2 recreates every PDS. Jetstream with no relay reads the first PDS itself and exits
254
+ // while it is gone; on a slow machine (a CI runner) it was still restarting when compose
255
+ // checked it, and up failed with "jetstream is unhealthy". Stopped first, it is started
256
+ // again only once the PDS is healthy.
257
+ const running = facts('SG_SERVICES');
258
+ if (running.includes('jetstream') && !running.includes('relay')) compose(['stop', 'jetstream'], { stdio: 'ignore' });
215
259
  await upStack();
216
260
  }
217
261
 
package/lib/config.mjs CHANGED
@@ -214,6 +214,7 @@ export function checkGlobe(doc, { baseDir } = {}) {
214
214
  }
215
215
 
216
216
  // services
217
+ let tap = null;
217
218
  const services = { relay: 'auto', jetstream: true, tap: false, slingshot: true, pdsls: true, cdn: false };
218
219
  if (doc.services !== undefined) {
219
220
  if (!isMap(doc.services)) err('services', 'must be a map');
@@ -223,7 +224,10 @@ export function checkGlobe(doc, { baseDir } = {}) {
223
224
  const v = doc.services[k];
224
225
  if (v === undefined) continue;
225
226
  if (typeof v === 'boolean' || (k === 'relay' && v === 'auto')) services[k] = v;
226
- else err(`services.${k}`, `must be true or false${k === 'relay' ? ', or auto' : ''}`);
227
+ else if (k === 'tap' && isMap(v)) {
228
+ services.tap = true;
229
+ tap = readTap(v, err);
230
+ } else err(`services.${k}`, `must be true or false${k === 'relay' ? ', or auto' : k === 'tap' ? ', or a map of webhook, signal_collection and collection_filters' : ''}`);
227
231
  }
228
232
  }
229
233
  }
@@ -374,7 +378,7 @@ export function checkGlobe(doc, { baseDir } = {}) {
374
378
  }
375
379
  }
376
380
 
377
- const globe = { globe: doc.globe, domain, pds, grouphosts, services, lexicons, accounts, apps, hosted };
381
+ const globe = { globe: doc.globe, domain, pds, grouphosts, services, tap, lexicons, accounts, apps, hosted };
378
382
  // The names in the apps' settings, against the file as it now stands. Left until the rest is sound,
379
383
  // or a PDS that failed its own check would be reported again as an unknown name.
380
384
  if (!errors.length) for (const [where, msg] of checkSettings(globe)) err(where, msg);
@@ -460,6 +464,31 @@ function label(v, where, err) {
460
464
  err(where, `${show(v)} must be one lowercase DNS label (letters, digits, inner hyphens)`);
461
465
  }
462
466
 
467
+ // services.tap as a map: where Tap sends events, and which collection it follows. The webhook may
468
+ // hold ${...} placeholders (checkSettings looks them up); an account is found when it writes in the
469
+ // signal collection, so the filters default to that collection alone.
470
+ const NSID_RE = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+){2,}$/;
471
+ function readTap(map, err) {
472
+ unknownKeys(map, ['webhook', 'signal_collection', 'collection_filters'], 'services.tap', err);
473
+ const tap = { webhook: null, signal_collection: null, collection_filters: [] };
474
+ if (map.webhook !== undefined) {
475
+ if (typeof map.webhook !== 'string' || map.webhook === '') err('services.tap.webhook', 'must be a URL (it may hold ${...} names)');
476
+ else tap.webhook = map.webhook;
477
+ }
478
+ if (map.signal_collection !== undefined) {
479
+ if (typeof map.signal_collection !== 'string' || !NSID_RE.test(map.signal_collection))
480
+ err('services.tap.signal_collection', `${show(map.signal_collection)} must be a collection name, such as xyz.statusphere.status`);
481
+ else tap.signal_collection = map.signal_collection;
482
+ }
483
+ if (map.collection_filters !== undefined) {
484
+ if (!Array.isArray(map.collection_filters) || map.collection_filters.some((c) => typeof c !== 'string' || !NSID_RE.test(c)))
485
+ err('services.tap.collection_filters', 'must be a list of collection names');
486
+ else tap.collection_filters = map.collection_filters;
487
+ }
488
+ if (!tap.collection_filters.length && tap.signal_collection) tap.collection_filters = [tap.signal_collection];
489
+ return tap;
490
+ }
491
+
463
492
  function unknownKeys(map, allowed, where, err) {
464
493
  for (const k of Object.keys(map))
465
494
  if (!allowed.includes(k)) err(where ? `${where}.${k}` : k, `unknown setting; known here: ${allowed.join(', ')}`);
package/lib/env.mjs CHANGED
@@ -5,8 +5,9 @@
5
5
 
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { randomBytes } from 'node:crypto';
8
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
9
9
  import { request } from 'node:https';
10
+ import net from 'node:net';
10
11
  import { homedir } from 'node:os';
11
12
  import { dirname, isAbsolute, join, resolve } from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
@@ -75,8 +76,12 @@ export function paths(root = ROOT, state) {
75
76
  // The snowglobe's own logins: the base init's alice, bob and invite code, the lexicon
76
77
  // authority, and accounts made by snowglobe account.
77
78
  accounts: join(data, 'accounts.env'),
78
- // The PDSes' admin password, made by up (random, once) and read by account and invite.
79
- // A file of its own: the base init rewrites accounts.env.
79
+ // The secrets the services start with (SECRETS below), made by up, once, and read by account
80
+ // and invite. A file of its own: the base init rewrites accounts.env.
81
+ secrets: join(data, 'secrets.env'),
82
+ // The loopback address this snowglobe publishes its ports on (chooseBind), one line.
83
+ bind: join(data, 'bind'),
84
+ // Where the admin password was kept before secrets.env (read, never written, for a snowglobe made then).
80
85
  admin: join(data, 'admin.env'),
81
86
  // App routes, "<name> <port> [<ending>]" per line (snowglobe app).
82
87
  apps: join(https, 'apps'),
@@ -248,17 +253,110 @@ export function globeFacts({ root = ROOT, state, env = process.env } = {}) {
248
253
  };
249
254
  }
250
255
 
251
- // The address nginx publishes 443 on, worked out as compose works out ${SG_BIND:-127.0.0.1}.
252
- // A loopback address only (127.x.y.z, one per globe run side by side): the globe's logins are
253
- // throwaway, so it is never published beyond this machine.
254
- export function resolveBind({ root = ROOT, env = process.env } = {}) {
255
- const bind = setting('SG_BIND', { root, env }) || '127.0.0.1';
256
+ // The loopback address nginx and every other published port use, worked out as compose works out
257
+ // ${SG_BIND:-127.0.0.1}: SG_BIND where set, else the one `up` chose for this snowglobe (the bind
258
+ // file in its state folder), else 127.0.0.1. A loopback address only (127.x.y.z, one per globe run
259
+ // side by side): the globe's logins are throwaway, so it is never published beyond this machine.
260
+ export function resolveBind({ root = ROOT, state, env = process.env } = {}) {
261
+ const saved = state && existsSync(paths(root, state).bind) ? readFileSync(paths(root, state).bind, 'utf8').trim() : '';
262
+ const bind = setting('SG_BIND', { root, env }) || saved || '127.0.0.1';
256
263
  const parts = bind.split('.');
257
264
  if (parts.length !== 4 || parts[0] !== '127' || parts.slice(1).some((x) => !/^[0-9]+$/.test(x) || Number(x) > 255))
258
265
  throw new Error(`SG_BIND=${bind} is not a loopback address 127.x.y.z (set in the environment or .env)`);
259
266
  return bind;
260
267
  }
261
268
 
269
+ // The ports a snowglobe publishes on its address: nginx, and the tools (each can be moved with its SG_..._PORT).
270
+ // Tap's is probed whether or not this globe runs Tap: an address another globe's Tap answers on is not free.
271
+ const PUBLISHED = [['', 443], ['SG_PROXY_PORT', 1080], ['SG_MAILDEV_WEB_PORT', 1081], ['SG_MAILDEV_SMTP_PORT', 1026], ['SG_PLC_PORT', 2582], ['SG_JETSTREAM_PORT', 6008], ['SG_JETSTREAM_METRICS_PORT', 6009], ['SG_TAP_PORT', 2480]];
272
+
273
+ // Whether something already answers on any port a snowglobe would publish on a loopback address.
274
+ export async function addressBusy(address, { root = ROOT, env = process.env } = {}) {
275
+ const answers = (port) =>
276
+ new Promise((done) => {
277
+ const s = net.connect({ host: address, port, timeout: 500 });
278
+ s.on('connect', () => (s.destroy(), done(true)));
279
+ s.on('timeout', () => (s.destroy(), done(false)));
280
+ s.on('error', () => done(false));
281
+ });
282
+ const ports = PUBLISHED.map(([name, port]) => Number((name && setting(name, { root, env })) || port));
283
+ return (await Promise.all(ports.map(answers))).some(Boolean);
284
+ }
285
+
286
+ // Whether the compose project's own nginx is running now.
287
+ export function ownNginxUp(project, env = process.env) {
288
+ const res = spawnSync('docker', ['ps', '-q', '--filter', `label=com.docker.compose.project=${project}`, '--filter', 'label=com.docker.compose.service=nginx'], { encoding: 'utf8', env });
289
+ return !res.error && res.status === 0 && res.stdout.trim() !== '';
290
+ }
291
+
292
+ /**
293
+ * The loopback address this snowglobe publishes on, chosen once and kept in its state folder, so a
294
+ * second snowglobe on the machine starts beside the first with no settings: it takes the first
295
+ * 127.0.0.N that no other snowglobe in the same user folder has kept and nothing answers on 443 or any other port it publishes.
296
+ * SG_BIND, where set, wins and nothing is written. A snowglobe that is already running with no
297
+ * address kept was made before this and is on 127.0.0.1, so it keeps that. Returns the address.
298
+ */
299
+ export async function chooseBind({ root = ROOT, state, project, env = process.env, listening = (a) => addressBusy(a, { root, env }), ownUp = ownNginxUp } = {}) {
300
+ const p = paths(root, state);
301
+ if (setting('SG_BIND', { root, env }) || existsSync(p.bind)) return resolveBind({ root, state, env });
302
+ // Choosing reads the other bind files, waits on probes, then writes ours, so two first starts at
303
+ // once would both pick the same address and keep it. One at a time, under a lock beside the state folders.
304
+ return withLock(join(dirname(state), '.bind-lock'), async () => {
305
+ if (existsSync(p.bind)) return resolveBind({ root, state, env });
306
+ return pickBind({ p, state, project, env, listening, ownUp });
307
+ });
308
+ }
309
+
310
+ // Run `fn` holding a lock directory (mkdir is atomic). A lock older than `stale` ms was left by a
311
+ // process that died. It is not taken over, since two starts taking it over at once could both
312
+ // hold it; up stops and names the folder to remove.
313
+ async function withLock(dir, fn, { stale = 60000, wait = 30000 } = {}) {
314
+ mkdirSync(dirname(dir), { recursive: true });
315
+ const until = Date.now() + wait;
316
+ for (;;) {
317
+ try {
318
+ mkdirSync(dir);
319
+ break;
320
+ } catch (e) {
321
+ if (e.code !== 'EEXIST') throw e;
322
+ let age;
323
+ try {
324
+ age = Date.now() - statSync(dir).mtimeMs;
325
+ } catch {
326
+ continue; // released between the two calls
327
+ }
328
+ if (age > stale) throw new Error(`a snowglobe that stopped while choosing an address left ${dir}; remove that folder, then up again`);
329
+ if (Date.now() > until) throw new Error(`another snowglobe is choosing an address (${dir}); remove that folder if none is`);
330
+ await new Promise((r) => setTimeout(r, 25));
331
+ }
332
+ }
333
+ try {
334
+ return await fn();
335
+ } finally {
336
+ rmSync(dir, { recursive: true, force: true });
337
+ }
338
+ }
339
+
340
+ async function pickBind({ p, state, project, env, listening, ownUp }) {
341
+ const used = new Set();
342
+ const top = dirname(state);
343
+ for (const d of existsSync(top) ? readdirSync(top) : []) {
344
+ const f = join(top, d, 'bind');
345
+ if (join(top, d) !== state && existsSync(f)) used.add(readFileSync(f, 'utf8').trim());
346
+ }
347
+ let bind = '127.0.0.1';
348
+ if (!ownUp(project, env)) {
349
+ bind = '';
350
+ for (let n = 1; n <= 50 && !bind; n++) {
351
+ const a = `127.0.0.${n}`;
352
+ if (!used.has(a) && !(await listening(a))) bind = a;
353
+ }
354
+ if (!bind) throw new Error('every loopback address from 127.0.0.1 to 127.0.0.50 is in use; set SG_BIND to one that is free');
355
+ }
356
+ writeFileSync(p.bind, `${bind}\n`);
357
+ return bind;
358
+ }
359
+
262
360
  // Replace the lines of an env file matching `drop` with `add` (lines, no newlines). The file is
263
361
  // mode 600, and never readable by anyone else, even briefly: it is written aside at 600, then
264
362
  // renamed over the old one.
@@ -299,22 +397,86 @@ export function addName(host, { root = ROOT, state } = {}) {
299
397
  if (!lines(p.names).includes(host)) appendFileSync(p.names, `${host}\n`);
300
398
  }
301
399
 
302
- // The PDSes' admin password: SG_PDS_ADMIN_PASSWORD from the environment or .env (a globe on the
303
- // cluster has its own), else the one up made for this snowglobe in its admin.env; '' when neither.
304
- export function adminPassword({ root = ROOT, state, env = process.env } = {}) {
305
- return setting('SG_PDS_ADMIN_PASSWORD', { root, env }) || (state ? envGet('SG_PDS_ADMIN_PASSWORD', paths(root, state).admin) : '');
306
- }
400
+ // The secrets a snowglobe's services start with: the PDSes' admin password, JWT secret and PLC
401
+ // rotation key, the relay's admin password and the PLC database's. `up` makes each one random,
402
+ // once, per snowglobe (ensureSecrets); the compose files take them as ${SG_...:?}, so none has a
403
+ // default. `bytes` is how many random bytes (the rotation key is a 32-byte secp256k1 key).
404
+ // `legacy` is the fixed value every snowglobe used before they were random: a snowglobe whose data
405
+ // was made under it keeps it, because Postgres keeps the password it was first started with and
406
+ // a DID's PLC record names the rotation key that made it. The admin password was never fixed.
407
+ export const SECRETS = {
408
+ SG_PDS_ADMIN_PASSWORD: { bytes: 24 },
409
+ SG_PDS_JWT_SECRET: { bytes: 24, legacy: 'devnet-jwt-secret-not-for-production' },
410
+ SG_PLC_ROTATION_KEY: { bytes: 32, legacy: '0123456789abcdef'.repeat(4) },
411
+ SG_RELAY_ADMIN_PASSWORD: { bytes: 24, legacy: 'devnet-relay-admin' },
412
+ SG_DB_PASSWORD: { bytes: 24, legacy: 'postgres' },
413
+ };
307
414
 
308
- // adminPassword, made first when there is none: 24 random bytes, kept in admin.env (mode 600) so
309
- // the same one is used on every later up and read by account and invite. Never printed.
310
- export function ensureAdminPassword({ root = ROOT, state, env = process.env } = {}) {
311
- const have = adminPassword({ root, state, env });
312
- if (have) return have;
415
+ // A secret's value: the environment's or .env's (a globe on the cluster has its own), else the
416
+ // one up made for this snowglobe in its secrets.env (for the admin password, else its older admin.env); '' when none.
417
+ export function secret(name, { root = ROOT, state, env = process.env } = {}) {
418
+ const set = setting(name, { root, env });
419
+ if (set || !state) return set || '';
313
420
  const p = paths(root, state);
314
- mkdirSync(p.data, { recursive: true });
315
- const password = randomBytes(24).toString('hex');
316
- envPut(loginsFile(p.admin), /^SG_PDS_ADMIN_PASSWORD=/, [`SG_PDS_ADMIN_PASSWORD=${password}`]);
317
- return password;
421
+ // The admin password up made before secrets.env existed is still the snowglobe's: its PDSes
422
+ // were started with it, so it is kept, not replaced by a new one.
423
+ return envGet(name, p.secrets) || (name === 'SG_PDS_ADMIN_PASSWORD' ? envGet(name, p.admin) : '');
424
+ }
425
+
426
+ export const adminPassword = (opts) => secret('SG_PDS_ADMIN_PASSWORD', opts);
427
+
428
+ /**
429
+ * Every secret in SECRETS, made first where there is none: random, and kept in secrets.env (mode
430
+ * 600) so the same one is used on every later up. Where the snowglobe already has data (`hasData()`
431
+ * is true, asked only when a secret that used to be fixed is missing; null, Docker could not say, is refused) a secret that used to be fixed keeps its fixed
432
+ * value instead. Returns { values, kept }: each secret's value, and the names that kept a fixed
433
+ * one. A value set in the environment or .env is used and not written. Never prints a value.
434
+ */
435
+ export function ensureSecrets({ root = ROOT, state, env = process.env, hasData = () => false } = {}) {
436
+ const values = {};
437
+ const kept = [];
438
+ const made = [];
439
+ let data;
440
+ for (const [name, { bytes, legacy }] of Object.entries(SECRETS)) {
441
+ let value = secret(name, { root, state, env });
442
+ if (!value) {
443
+ // Only a secret that used to be fixed depends on whether there is data; the answer is asked once.
444
+ if (legacy !== undefined && data === undefined) {
445
+ data = hasData();
446
+ if (data === null) throw new Error('Docker did not say whether this snowglobe already has data (docker volume ls failed), so up will not choose its secrets yet: start Docker, then run up again');
447
+ }
448
+ if (legacy !== undefined && data) {
449
+ value = legacy;
450
+ kept.push(name);
451
+ } else value = randomBytes(bytes).toString('hex');
452
+ made.push(name);
453
+ }
454
+ values[name] = value;
455
+ }
456
+ if (made.length) {
457
+ const p = paths(root, state);
458
+ mkdirSync(p.data, { recursive: true });
459
+ envPut(loginsFile(p.secrets), new RegExp(`^(${made.join('|')})=`), made.map((n) => `${n}=${values[n]}`));
460
+ }
461
+ return { values, kept };
462
+ }
463
+
464
+ /** Whether Docker holds volumes for the compose project: null when Docker cannot say, which is not an answer to act on. */
465
+ export function projectHasData(project, env = process.env) {
466
+ const res = spawnSync('docker', ['volume', 'ls', '-q', '--filter', `label=com.docker.compose.project=${project}`], { encoding: 'utf8', env });
467
+ return res.error || res.status !== 0 ? null : res.stdout.trim() !== '';
468
+ }
469
+
470
+ /**
471
+ * After `down -v`, the fixed secrets (SECRETS' legacy values) are dropped from secrets.env: the
472
+ * data they were kept for is gone, so the next up makes random ones. Random ones stay, and a
473
+ * secret set in the environment or .env is not in the file.
474
+ */
475
+ export function dropLegacySecrets({ root = ROOT, state } = {}) {
476
+ const file = paths(root, state).secrets;
477
+ if (!existsSync(file)) return;
478
+ const old = Object.entries(SECRETS).filter(([n, { legacy }]) => legacy !== undefined && envGet(n, file) === legacy).map(([n]) => n);
479
+ if (old.length) envPut(file, new RegExp(`^(${old.join('|')})=`));
318
480
  }
319
481
 
320
482
  // docker compose on `project` with the rendered globe. Its paths are from the repo root, which is
@@ -326,15 +488,15 @@ export function dc(project, args, { root = ROOT, state, stdio = 'inherit', encod
326
488
  const old = oldDataNotice(root, state);
327
489
  throw new Error(`No ${p.compose}: run snowglobe up, which renders it${old ? `\n${old}` : ''}`);
328
490
  }
329
- // compose fills ${SG_PDS_ADMIN_PASSWORD} in the file from this, and refuses to read the file
330
- // without it. up makes the real one first; down and ps on a snowglobe from before it have none,
331
- // and need only a value for compose to read the file.
491
+ // compose fills the file's ${SG_...:?} secrets from these, and refuses to read the file without
492
+ // them. up makes the real ones first; down and ps on a snowglobe that up has not made them for
493
+ // need only a value for compose to read the file.
332
494
  const base = env ?? process.env;
333
- const password = adminPassword({ root, state, env: base }) || 'unset-run-snowglobe-up';
495
+ const secrets = Object.fromEntries(Object.keys(SECRETS).map((n) => [n, secret(n, { root, state, env: base }) || 'unset-run-snowglobe-up']));
334
496
  return spawnSync('docker', ['compose', '-p', project, '--project-directory', root, '-f', p.compose, ...args], {
335
497
  stdio,
336
498
  encoding,
337
- env: { ...base, SG_PDS_ADMIN_PASSWORD: password },
499
+ env: { ...base, ...secrets, SG_BIND: resolveBind({ root, state, env: base }) },
338
500
  });
339
501
  }
340
502
 
@@ -346,7 +508,7 @@ export function snowglobeRequest(url, { method = 'GET', headers = {}, body, root
346
508
  const pub = env.SG_PUBLIC === '1';
347
509
  const options = { method, headers };
348
510
  if (!pub) {
349
- const bind = resolveBind({ root, env });
511
+ const bind = resolveBind({ root, state, env });
350
512
  options.ca = readFileSync(paths(root, state).ca);
351
513
  options.lookup = (_host, opts, cb) =>
352
514
  opts?.all ? cb(null, [{ address: bind, family: 4 }]) : cb(null, bind, 4);
@@ -0,0 +1,56 @@
1
+ // The images snowglobe builds from its own folders that are also published (scripts/publish-images.mjs),
2
+ // so `up` pulls them where it can and builds only what it cannot. A pulled image is used only when
3
+ // its source label equals the hash of the folder's files in this checkout, so an edited Dockerfile
4
+ // or patch is built, never silently served from the registry.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+ import { contextHash } from './render.mjs';
8
+
9
+ export const REGISTRY = 'ghcr.io/openmeet-team';
10
+ export const SOURCE_LABEL = 'at.snowglobe.source';
11
+
12
+ // Image name (the part of the compose image before the tag): its folder, and the files its Dockerfile uses.
13
+ export const PUBLISHED = {
14
+ 'snowglobe-slingshot': ['stack/slingshot', ['Dockerfile']],
15
+ 'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
16
+ 'snowglobe-relay': ['stack/relay', ['Dockerfile', 'allow-private-hosts.patch']],
17
+ };
18
+
19
+ /** The source label an image of this name should carry for the files in `root`. */
20
+ export const sourceHash = (root, name) => contextHash(root, ...PUBLISHED[name]);
21
+
22
+ const docker = (args) => spawnSync('docker', args, { encoding: 'utf8', stdio: 'pipe' });
23
+
24
+ /**
25
+ * Of the rendered compose file's services, those whose image is already here or can be pulled
26
+ * from the registry with a matching source label. Returns their names; the caller builds the rest.
27
+ * SG_PREBUILT=0 pulls nothing and uses nothing (an image is then built, as it always was).
28
+ */
29
+ export function prebuilt({ root, services, env = process.env, run = docker, log = () => {} }) {
30
+ if (env.SG_PREBUILT === '0') return [];
31
+ const registry = (env.SG_IMAGE_REGISTRY ?? REGISTRY).replace(/\/$/, '');
32
+ const label = (ref) => {
33
+ const r = run(['image', 'inspect', '-f', `{{index .Config.Labels "${SOURCE_LABEL}"}}`, ref]);
34
+ return r.status === 0 ? r.stdout.trim() : null;
35
+ };
36
+ const ready = [];
37
+ const pulled = [];
38
+ for (const [service, def] of Object.entries(services)) {
39
+ const ref = def.build && def.image;
40
+ const name = ref?.split(':')[0];
41
+ if (!(name in PUBLISHED)) continue;
42
+ const want = sourceHash(root, name);
43
+ if (label(ref) === want) {
44
+ ready.push(service);
45
+ continue;
46
+ }
47
+ if (!registry) continue;
48
+ if (run(['pull', '-q', `${registry}/${ref}`]).status !== 0) continue;
49
+ if (run(['tag', `${registry}/${ref}`, ref]).status === 0 && label(ref) === want) {
50
+ ready.push(service);
51
+ pulled.push(service);
52
+ }
53
+ }
54
+ if (pulled.length) log(`Pulled prebuilt images (${pulled.join(' ')})`);
55
+ return ready;
56
+ }
package/lib/render.mjs CHANGED
@@ -19,6 +19,7 @@ import { readdirSync, readFileSync } from 'node:fs';
19
19
  import { dirname, isAbsolute, join } from 'node:path';
20
20
  import { parse, stringify } from 'yaml';
21
21
  import { IMAGE_NAMES, RESERVED } from './config.mjs';
22
+ import { lookup, parseValue } from './settings.mjs';
22
23
 
23
24
  // Every PDS listens on this port inside its own container, and every group host on the other.
24
25
  export const PDS_PORT = 3000;
@@ -60,6 +61,18 @@ export function loadBase(root) {
60
61
  };
61
62
  }
62
63
 
64
+ /** TAP's settings from services.tap's map: where it sends events and which collection it follows. */
65
+ function tapSettings(globe) {
66
+ const t = globe.tap;
67
+ if (!t) return {};
68
+ const webhook = t.webhook && parseValue(t.webhook).parts.map((x) => (x.text !== undefined ? x.text : lookup(globe, x.name).value)).join('');
69
+ return {
70
+ ...(webhook && { TAP_WEBHOOK_URL: webhook }),
71
+ ...(t.signal_collection && { TAP_SIGNAL_COLLECTION: t.signal_collection }),
72
+ ...(t.collection_filters.length && { TAP_COLLECTION_FILTERS: t.collection_filters.join(',') }),
73
+ };
74
+ }
75
+
63
76
  /**
64
77
  * Render a checked globe. stateDir is the snowglobe's state folder (an absolute path): the
65
78
  * compose file mounts the CA (in ca/ beside it), the leaf and nginx's routes from it, and init
@@ -103,15 +116,15 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
103
116
  services.maildev = {
104
117
  ...copy(base.test.maildev),
105
118
  ports: [
106
- '127.0.0.1:${SG_MAILDEV_WEB_PORT:-1081}:1080',
107
- '127.0.0.1:${SG_MAILDEV_SMTP_PORT:-1026}:1025',
119
+ '${SG_BIND:-127.0.0.1}:${SG_MAILDEV_WEB_PORT:-1081}:1080',
120
+ '${SG_BIND:-127.0.0.1}:${SG_MAILDEV_SMTP_PORT:-1026}:1025',
108
121
  ],
109
122
  };
110
123
 
111
124
  services.plc = {
112
125
  ...copy(base.base.plc),
113
126
  depends_on: { postgres: healthy },
114
- ports: ['127.0.0.1:${SG_PLC_PORT:-2582}:2582'],
127
+ ports: ['${SG_BIND:-127.0.0.1}:${SG_PLC_PORT:-2582}:2582'],
115
128
  };
116
129
 
117
130
  const pdsBase = base.base.pds;
@@ -254,21 +267,26 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
254
267
  JETSTREAM_WS_URL: `${feed.replace(/^http/, 'ws')}/xrpc/com.atproto.sync.subscribeRepos`,
255
268
  },
256
269
  ports: [
257
- '127.0.0.1:${SG_JETSTREAM_PORT:-6008}:6008',
258
- '127.0.0.1:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
270
+ '${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_PORT:-6008}:6008',
271
+ '${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
259
272
  ],
260
273
  };
261
274
  }
262
275
 
263
276
  if (on.tap) {
264
- // Resync fetches repos through an SSRF-safe transport, which refuses nginx's address, so
265
- // TAP follows the stream but cannot resync a repo here.
277
+ // TAP runs in tap-dns's network namespace, whose DNS answers every name under the domain with
278
+ // nginx: so it reaches the webhook's app and checks a handle at its https name. It follows the
279
+ // stream and finds an account when it writes in the signal collection; its crawl of the
280
+ // collection's repos is refused by the PDS (HTTP 401) and finds none.
266
281
  services.tap = {
267
282
  ...copy(base.base.tap),
268
283
  depends_on: on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy },
269
- environment: { ...copy(base.base.tap.environment), TAP_RELAY_URL: feed },
284
+ environment: { ...copy(base.base.tap.environment), TAP_RELAY_URL: feed, ...tapSettings(globe) },
270
285
  volumes: [...base.base.tap.volumes, GO_CA],
286
+ network_mode: 'service:tap-dns',
287
+ depends_on: { 'tap-dns': { condition: 'service_started' }, ...(on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy }) },
271
288
  };
289
+ delete services.tap.networks;
272
290
  delete services.tap.ports;
273
291
  }
274
292
 
@@ -326,6 +344,19 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
326
344
  healthcheck: { test: ['CMD', 'nginx', '-t', '-q'], interval: '5s', timeout: '3s', retries: 10 },
327
345
  };
328
346
 
347
+ if (on.tap) {
348
+ services['tap-dns'] = {
349
+ build: './stack/https/dns',
350
+ image: base.tags['snowglobe-dns'],
351
+ restart: 'unless-stopped',
352
+ environment: { SG_DOMAIN: D },
353
+ dns: ['127.0.0.1'],
354
+ depends_on: { nginx: healthy },
355
+ ports: ['${SG_BIND:-127.0.0.1}:${SG_TAP_PORT:-2480}:2480'],
356
+ networks: [NETWORK],
357
+ };
358
+ }
359
+
329
360
  if (on.slingshot) {
330
361
  // Slingshot (microcosm-rs) answers com.bad-example.identity.resolveMiniDoc, a handle's DID
331
362
  // and PDS, from this globe's PLC and Jetstream. It reaches the handles' names over TLS,
@@ -377,7 +408,7 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
377
408
  restart: 'unless-stopped',
378
409
  depends_on: { nginx: healthy },
379
410
  environment: { PROXY_DOMAIN: D, PROXY_UPSTREAM: 'nginx:443' },
380
- ports: ['127.0.0.1:${SG_PROXY_PORT:-1080}:1080'],
411
+ ports: ['${SG_BIND:-127.0.0.1}:${SG_PROXY_PORT:-1080}:1080'],
381
412
  networks: [NETWORK],
382
413
  healthcheck: { test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:1080/_health'], interval: '5s', timeout: '3s', retries: 10 },
383
414
  };
@@ -421,14 +452,14 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
421
452
  }
422
453
 
423
454
  const header = (c) =>
424
- `${c} Written by bin/snowglobe.mjs render from ${source}; render again instead of editing.\n`;
455
+ `${c} Written by snowglobe render from ${source}; render again instead of editing.\n`;
425
456
 
426
457
  const compose =
427
458
  header('#') +
428
459
  `# The https globe "${globe.globe}" at ${D}: ${globe.pds.map((p) => p.name).join(', ')}` +
429
460
  (globe.grouphosts.length ? `; group hosts ${globe.grouphosts.map((g) => g.name).join(', ')}` : '') +
430
461
  '.\n' +
431
- '# Run it with bin/snowglobe.mjs up, which renders this file first.\n\n' +
462
+ '# Run it with snowglobe up, which renders this file first.\n\n' +
432
463
  // YAML 1.1 quoting, so a value such as restart's "no" stays a string to compose.
433
464
  stringify({ services, volumes, networks: { [NETWORK]: null } }, { lineWidth: 0, nullStr: '', aliasDuplicateObjects: false, version: '1.1' });
434
465
 
@@ -483,15 +514,18 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
483
514
  const named = Object.entries(IMAGE_NAMES).find(([, ref]) => ref === image)?.[0];
484
515
  return named ?? image.split('/').pop().replace(/@sha256:([0-9a-f]{12})[0-9a-f]+$/, '@sha256:$1…');
485
516
  };
517
+ // What the globe is made of, as the page's first line names it: only what runs.
518
+ const parts = [globe.pds.length === 1 ? 'PDS' : 'PDSs', 'PLC directory', on.relay && 'relay', on.jetstream && 'Jetstream'].filter(Boolean);
486
519
  const page = {
487
520
  globe: globe.globe,
488
521
  domain: D,
489
522
  about:
490
- 'An atproto network on this machine: its own PDSs, PLC directory, relay and Jetstream. Its accounts live in its own directory, not the public one, so Bluesky cannot find anything here. An app is handed this network\'s addresses (snowglobe env), not Bluesky\'s.',
523
+ `An atproto network on this machine: its own ${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}. Its accounts live in its own directory, not the public one, so Bluesky cannot find anything here. An app is handed this network's addresses (snowglobe env), not Bluesky's.`,
491
524
  accountsNote:
492
525
  `Every account on the network, on each of its ${globe.pds.length} PDS${globe.pds.length === 1 ? '' : 's'}` +
493
526
  (globe.grouphosts.length ? `, and every group on its group host${globe.grouphosts.length === 1 ? '' : 's'}.` : '.'),
494
- ...(on.jetstream ? { feed: '/subscribe' } : {}),
527
+ // Without Jetstream the page has no feed to follow, and says so instead of reconnecting.
528
+ feed: on.jetstream ? '/subscribe' : false,
495
529
  services: [
496
530
  ...globe.pds.map((p) => [
497
531
  'PDS',
package/lib/settings.mjs CHANGED
@@ -93,13 +93,13 @@ export function lookup(globe, name, later) {
93
93
  slingshot: { url: `https://slingshot.${D}` },
94
94
  pdsls: { url: `https://pdsls.${D}` },
95
95
  mail: { url: null },
96
+ tap: { url: null },
96
97
  };
97
- if (svc === 'tap') return fail('services.tap has no address an app can take (it has no https name)');
98
98
  if (!svc || !Object.hasOwn(svcs, svc)) return fail(`${svc ? `services.${svc}` : name} is not a service with an address: ${list(Object.keys(svcs))}`);
99
99
  if (field === undefined || more.length || !Object.hasOwn(svcs[svc], field))
100
100
  return fail(`${name} is not a value of services.${svc}; it has ${list(Object.keys(svcs[svc]).map((f) => `services.${svc}.${f}`))}`);
101
101
  if (svc !== 'plc' && svc !== 'mail' && !globe.services[svc]) return fail(`services.${svc} is off in this file, so it has no address`);
102
- return svc === 'mail' ? after() : { value: svcs[svc][field] };
102
+ return svc === 'mail' || svc === 'tap' ? after() : { value: svcs[svc][field] };
103
103
  }
104
104
  case 'accounts': {
105
105
  if (rest.length === 1 && rest[0] === 'file') return after();
@@ -130,6 +130,16 @@ export function lookup(globe, name, later) {
130
130
  /** The problems with the names in every apps[].env of `globe`, as [where, message] pairs. */
131
131
  export function checkSettings(globe) {
132
132
  const problems = [];
133
+ if (globe.tap?.webhook) {
134
+ const { parts, error } = parseValue(globe.tap.webhook);
135
+ if (error) problems.push(['services.tap.webhook', error]);
136
+ else
137
+ for (const part of parts) {
138
+ const found = part.name && lookup(globe, part.name);
139
+ if (found?.error) problems.push(['services.tap.webhook', found.error]);
140
+ else if (found?.later) problems.push(['services.tap.webhook', `${part.name} is known only after snowglobe up, so Tap cannot be given it`]);
141
+ }
142
+ }
133
143
  globe.apps.forEach((app, i) => {
134
144
  for (const [key, text] of Object.entries(app.env)) {
135
145
  const at = `apps[${i}].env.${key}`;
package/lib/state.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // folder records which file owns it, so two files that say `globe: t1` cannot share one folder,
6
6
  // or one compose project.
7
7
 
8
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { globeName } from './config.mjs';
11
11
  import { ROOT, stateRoot } from './env.mjs';
@@ -35,6 +35,40 @@ export function findFile({ file, env = process.env, cwd = process.cwd(), allowDe
35
35
  // The file that owns a state folder is recorded here, as an absolute path.
36
36
  const OWNER = 'owner';
37
37
 
38
+ /** The file recorded as owning the state folder `state`, or '' when none is. */
39
+ export function ownerOf(state) {
40
+ const record = join(state, OWNER);
41
+ return existsSync(record) ? readFileSync(record, 'utf8').trim() : '';
42
+ }
43
+
44
+ /**
45
+ * The file `up` or `down` was pointed at, from its -f option and its one word, which is a file
46
+ * or the name of a snowglobe (snowglobe ls shows them): { file } for a file, { name } for a
47
+ * snowglobe, {} for neither given (the file found from the folder you are in). A word that is a
48
+ * file that exists is that file; else it is a name when a state folder is called that. Throws
49
+ * when it is neither, or both -f and a word are given.
50
+ */
51
+ export function pointedAt({ file, word, env = process.env } = {}) {
52
+ if (file && word) throw new Error(`give the snowglobe once: -f ${file} or ${word}, not both`);
53
+ if (file) return { file };
54
+ if (!word) return {};
55
+ if (existsSync(word) && statSync(word).isFile()) return { file: word };
56
+ if (!/[\\/]/.test(word) && existsSync(join(stateRoot(env), word))) return { name: word };
57
+ throw new Error(`${word} is not a file, and no snowglobe is called that (snowglobe ls lists them)`);
58
+ }
59
+
60
+ /**
61
+ * The snowglobe called `name`, as session returns it, for a command that stops or reads one
62
+ * without its file: `path` is the file recorded as its owner, '' when that file is gone. Throws
63
+ * when no state folder has the name.
64
+ */
65
+ export function byName(name, { env = process.env } = {}) {
66
+ const state = join(stateRoot(env), name);
67
+ if (!existsSync(state)) throw new Error(`no snowglobe is called ${name} (snowglobe ls lists them)`);
68
+ const owner = ownerOf(state);
69
+ return { path: owner && existsSync(owner) ? owner : '', name, state, project: env.SG_PROJECT || name };
70
+ }
71
+
38
72
  /**
39
73
  * Refuse a state folder whose recorded owner is another file that still exists. With `claim`, a
40
74
  * folder with no owner, or whose owner is gone (moved or deleted), is recorded as this file's.
@@ -43,7 +77,7 @@ function owned({ name, state }, file, claim) {
43
77
  // Returns a line to tell the user when a file took over a name whose first file is gone.
44
78
  const mine = realpathSync(resolve(file));
45
79
  const record = join(state, OWNER);
46
- const first = existsSync(record) ? readFileSync(record, 'utf8').trim() : '';
80
+ const first = ownerOf(state);
47
81
  if (first && first !== mine && existsSync(first))
48
82
  throw new Error(
49
83
  `the snowglobe ${name} belongs to ${first} (its state is in ${state}), and ${mine} also says globe: ${name}. ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmeet/snowglobe",
3
- "version": "0.3.0-rc.0",
3
+ "version": "0.3.0",
4
4
  "description": "Standalone, testable ATProto development network for local development and CI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,11 @@
1
1
  # The default snowglobe: one current PDS, a PLC directory and Jetstream, all at https names, with
2
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.
3
+ # Copy this file to snowglobe.yml (here, or in your app's repo) and run snowglobe up
4
+ # (bin/snowglobe.mjs up from a checkout of snowglobe). The commented sections below add more.
5
+ # What each one does:
6
+ # https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/snowglobe.md
7
+ # The whole thing, three PDSes, a relay and a lexicon authority:
8
+ # https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/examples/atmo.yml
6
9
 
7
10
  globe: snowglobe
8
11
  domain: local.snowglobe.at # every name below is under it
@@ -26,7 +29,7 @@ services: # these are the defaults, each true or false
26
29
  jetstream: true
27
30
  slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
28
31
  pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
29
- tap: false # syncs and backfills repos for an app that attaches to it
32
+ tap: false # sends an app the events of one collection (true, or a map; see snowglobe.md above)
30
33
  # relay: auto # auto: on when there is more than one PDS
31
34
 
32
35
  # Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
@@ -45,7 +48,8 @@ accounts: # <name>@<pds>; up makes each one
45
48
  - bob@main
46
49
 
47
50
  # 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.
51
+ # `snowglobe env` hands them over. The names:
52
+ # https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/your-app.md
49
53
  # apps:
50
54
  # - name: myapp
51
55
  # local: { port: 5173 }
@@ -14,7 +14,7 @@
14
14
  # A relay only adds localhost hosts through its admin API, so a PDS cannot register
15
15
  # itself with requestCrawl. relay-init adds each PDS once the relay is up.
16
16
 
17
- x-relay-admin-password: &relay-admin-password ${SG_RELAY_ADMIN_PASSWORD:-${DEVNET_RELAY_ADMIN_PASSWORD:-devnet-relay-admin}}
17
+ x-relay-admin-password: &relay-admin-password ${SG_RELAY_ADMIN_PASSWORD:?snowglobe up makes it}
18
18
 
19
19
  services:
20
20
  pds:
@@ -9,7 +9,7 @@ services:
9
9
  restart: unless-stopped
10
10
  environment:
11
11
  POSTGRES_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
12
- POSTGRES_PASSWORD: ${SG_DB_PASSWORD:-${DEVNET_DB_PASSWORD:-postgres}}
12
+ POSTGRES_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
13
13
  POSTGRES_DB: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
14
14
  ports:
15
15
  - "${SG_POSTGRES_PORT:-${DEVNET_POSTGRES_PORT:-5433}}:5432"
@@ -25,11 +25,11 @@ services:
25
25
  environment:
26
26
  PORT: 2582
27
27
  DEVNET_DB_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
28
- DEVNET_DB_PASSWORD: ${SG_DB_PASSWORD:-${DEVNET_DB_PASSWORD:-postgres}}
28
+ DEVNET_DB_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
29
29
  DEVNET_DB_HOST: ${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}
30
30
  DEVNET_DB_PORT: ${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}
31
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:-${DEVNET_DB_PASSWORD:-postgres}}@${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}:${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}/${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
33
  ENABLE_MIGRATIONS: "true"
34
34
  LOG_ENABLED: "true"
35
35
  LOG_LEVEL: info
@@ -78,8 +78,8 @@ services:
78
78
  PDS_DISABLE_SSRF_PROTECTION: "true"
79
79
  PDS_INVITE_REQUIRED: "true"
80
80
  PDS_ADMIN_PASSWORD: ${SG_PDS_ADMIN_PASSWORD:?snowglobe up makes it}
81
- PDS_JWT_SECRET: devnet-jwt-secret-not-for-production
82
- PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
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
83
  PDS_EMAIL_SMTP_URL: ${SG_SMTP_URL:-${DEVNET_SMTP_URL:-smtp://maildev:1025}}
84
84
  PDS_EMAIL_FROM_ADDRESS: noreply@devnet.test
85
85
  PDS_HANDLE_DOMAINS: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
@@ -124,7 +124,8 @@ services:
124
124
 
125
125
  # ── TAP (repo sync + backfill) ───────────────────────────────────
126
126
  tap:
127
- image: ghcr.io/bluesky-social/indigo/tap:latest
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
128
129
  restart: unless-stopped
129
130
  depends_on:
130
131
  pds:
@@ -96,7 +96,7 @@ server {
96
96
  listen 443 ssl;
97
97
  server_name *.app.${SG_DOMAIN};
98
98
  if ($snowglobe_app = "") {
99
- return 404 "No app is registered at $host (bin/snowglobe.mjs app).\n";
99
+ return 404 "No app is registered at $host (snowglobe app).\n";
100
100
  }
101
101
  location / {
102
102
  proxy_pass http://$snowglobe_app;
@@ -233,7 +233,8 @@
233
233
  if (tally.people != null) parts.push(plural(tally.people, "person", "people"));
234
234
  if (tally.groups) parts.push(plural(tally.groups, "group", "groups"));
235
235
  var line = parts.length ? parts.join(" and ") + ". " : "";
236
- document.getElementById("tally").textContent = line + plural(tally.writes, "write", "writes") + " in the last 24 hours.";
236
+ // writes is null on a network with no feed to count them from.
237
+ document.getElementById("tally").textContent = line + (tally.writes == null ? "" : plural(tally.writes, "write", "writes") + " in the last 24 hours.");
237
238
  }
238
239
 
239
240
  function health(domain, services) {
@@ -380,6 +381,15 @@
380
381
  connect();
381
382
  }
382
383
 
384
+ // A network with no Jetstream has no feed: the panel says so instead of reconnecting forever.
385
+ function noActivity() {
386
+ document.getElementById("stream").style.display = "none";
387
+ document.getElementById("count").parentNode.style.display = "none";
388
+ var body = document.getElementById("events");
389
+ body.textContent = "";
390
+ add(body, el("p", { "class": "empty" }, "This network runs no Jetstream, so its writes are not followed here."));
391
+ }
392
+
383
393
  getJSON("/snowglobe.json").then(function (g) {
384
394
  document.title = g.globe + " · Snowglobe network";
385
395
  document.getElementById("title").textContent = g.globe;
@@ -396,12 +406,14 @@
396
406
  }
397
407
  if (g.about) document.getElementById("about").textContent = g.about;
398
408
  if (g.accountsNote) document.getElementById("accounts-note").textContent = g.accountsNote;
399
- var feed = g.feed || "wss://jetstream." + g.domain + "/subscribe";
400
- if (feed.charAt(0) === "/") feed = "wss://" + location.host + feed;
409
+ // feed false: the network runs no Jetstream. Unset, Jetstream is at its usual public name.
410
+ var feed = g.feed === false ? null : g.feed || "wss://jetstream." + g.domain + "/subscribe";
411
+ if (feed && feed.charAt(0) === "/") feed = "wss://" + location.host + feed;
412
+ if (!feed) { tally.writes = null; noActivity(); }
401
413
  showTally();
402
414
  health(g.domain, g.services || SERVICES);
403
415
  // The feed names accounts by handle, so it waits for the account list.
404
- accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { activity(feed); });
416
+ accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { if (feed) activity(feed); });
405
417
  });
406
418
  })();
407
419
  </script>