@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.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +148 -2
- package/bin/snowglobe.mjs +71 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +40 -0
- package/lib/commands/env.mjs +146 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/ls.mjs +72 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +301 -0
- package/lib/config.mjs +514 -0
- package/lib/env.mjs +526 -0
- package/lib/hosts.mjs +36 -0
- package/lib/prebuilt.mjs +56 -0
- package/lib/render.mjs +601 -0
- package/lib/settings.mjs +156 -0
- package/lib/state.mjs +109 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +179 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// snowglobe.yml: the one file that says what a globe runs. This reads it, fills in the
|
|
2
|
+
// defaults and checks it, without touching Docker. Every error names where in the file
|
|
3
|
+
// it is, so a bad file fails before anything starts.
|
|
4
|
+
//
|
|
5
|
+
// The file's shape and the reasons for each rule: OKF
|
|
6
|
+
// projects/atmo-events/2026-10-19-snowglobe-yml-design.md.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { parse } from 'yaml';
|
|
11
|
+
import { checkSettings } from './settings.mjs';
|
|
12
|
+
|
|
13
|
+
// Images a file may name by a short name. Each is pinned by digest, because the tags
|
|
14
|
+
// they come from move.
|
|
15
|
+
export const IMAGE_NAMES = {
|
|
16
|
+
// The atproto permissioned-spaces alpha, revision 79d6307e (2026-10-01).
|
|
17
|
+
'spaces-alpha':
|
|
18
|
+
'ghcr.io/bluesky-social/atproto@sha256:1cf9349e9f0e89789784569fed376cd647742ea7c368948625f44779ba03f150',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Kinds of group host. Each runs the file's image, or is built from the file's build folder, or
|
|
22
|
+
// else from the folder its environment variable names. opensocial (atproto-group-host) has no
|
|
23
|
+
// published image, so a local globe builds it from a checkout with the repo's own Dockerfile.
|
|
24
|
+
export const GROUPHOST_KINDS = {
|
|
25
|
+
opensocial: { image: null, buildEnv: 'SG_OPENSOCIAL_BUILD', source: 'a checkout of atproto-group-host' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Names the network already uses: under the domain (plc.<domain>, relay.<domain>, ...), as
|
|
29
|
+
// compose services the https snowglobe always runs, and as the page's own /health/<name> routes
|
|
30
|
+
// (stack/page/snowglobe.conf, where TAP is sync). A PDS, group host or bare handle by one of these names
|
|
31
|
+
// would take its address, its service or its route.
|
|
32
|
+
export const RESERVED = [
|
|
33
|
+
'plc', 'relay', 'jetstream', 'slingshot', 'pdsls', 'tap', 'mail', 'lex-authority',
|
|
34
|
+
'app', 'pds', 'nginx', 'postgres', 'maildev', 'proxy', 'init', 'relay-init', 'slingshot-dns', 'sync',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// The lexicon authority's account, made on its PDS before any account in the file.
|
|
38
|
+
const AUTHORITY_ACCOUNT = 'lex-authority';
|
|
39
|
+
|
|
40
|
+
// The domain a file that names none gets. It is ours and its DNS points at 127.0.0.1, so a name
|
|
41
|
+
// that escapes a globe lands on this machine, not on someone else's server. A domain under .test,
|
|
42
|
+
// .local, .localhost, .invalid or .example would fail sign-in: a PDS refuses an OAuth client there,
|
|
43
|
+
// and group hosts and PDSls are OAuth clients at their own names.
|
|
44
|
+
export const DEFAULT_DOMAIN = 'local.snowglobe.at';
|
|
45
|
+
|
|
46
|
+
// Names nginx answers whatever the domain: the stand-in for the real PLC directory at exactly
|
|
47
|
+
// plc.directory. A domain that is it, or above it, would share it. (Apps on this machine are
|
|
48
|
+
// under the domain, at app.<domain>.)
|
|
49
|
+
const FIXED_DOMAINS = [
|
|
50
|
+
{ name: 'plc.directory', wildcard: false },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const TOP = ['globe', 'domain', 'pds', 'grouphosts', 'services', 'lexicons', 'accounts', 'apps', 'hosted'];
|
|
54
|
+
const SERVICES = ['relay', 'jetstream', 'tap', 'slingshot', 'pdsls', 'cdn'];
|
|
55
|
+
|
|
56
|
+
// The globe's name is a compose project locally and a namespace hosted
|
|
57
|
+
// (snowglobe-<globe>), so it keeps the hosted stamp's rule (snowglobe-cloud).
|
|
58
|
+
const GLOBE_RE = /^[a-z][a-z0-9]{0,30}$/;
|
|
59
|
+
// A globe's state folder is named for it, beside ca/, where the user's certificate authority is.
|
|
60
|
+
const GLOBE_KEPT = ['ca'];
|
|
61
|
+
// A setting an app is handed is a name an environment holds.
|
|
62
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
63
|
+
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
64
|
+
const DOMAIN_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
65
|
+
// The longest a name may be, and so a handle (atproto's handle limit is DNS's).
|
|
66
|
+
const NAME_MAX = 253;
|
|
67
|
+
|
|
68
|
+
// An image reference, after github.com/distribution/reference: [registry/]path[:tag][@digest].
|
|
69
|
+
// As there, a first component is a registry when it has a dot, a port or an uppercase letter,
|
|
70
|
+
// is localhost, or is a bracketed IPv6 address; path components are lowercase; the name
|
|
71
|
+
// (registry and path) is at most 255 characters.
|
|
72
|
+
const HOST_C = '[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?';
|
|
73
|
+
const REGISTRY_RE = new RegExp(`^(?:${HOST_C}(?:\\.${HOST_C})*|\\[[0-9a-fA-F:.]+\\])(?::[0-9]+)?$`);
|
|
74
|
+
const PATH_RE = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/;
|
|
75
|
+
const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
76
|
+
const DIGEST_HEX = { sha256: 64, sha384: 96, sha512: 128 };
|
|
77
|
+
const IMAGE_NAME_MAX = 255;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read and check a file. Returns { globe, errors }; globe is the filled-in file. A domain
|
|
81
|
+
* given here replaces the file's, before the checks (a copy of a globe beside another).
|
|
82
|
+
*/
|
|
83
|
+
export function loadGlobe(file, { domain } = {}) {
|
|
84
|
+
let text;
|
|
85
|
+
try {
|
|
86
|
+
text = readFileSync(file, 'utf8');
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { globe: null, errors: [`${file}: cannot read it (${e.code ?? e.message})`] };
|
|
89
|
+
}
|
|
90
|
+
let doc;
|
|
91
|
+
try {
|
|
92
|
+
doc = parse(text);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { globe: null, errors: [`${file}: not YAML: ${e.message.split('\n')[0]}`] };
|
|
95
|
+
}
|
|
96
|
+
if (domain !== undefined && isMap(doc)) doc.domain = domain;
|
|
97
|
+
return checkGlobe(doc, { baseDir: dirname(resolve(file)) });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The `globe:` name of a file, which names its state folder and its compose project; the rest of
|
|
102
|
+
* the file is not checked (snowglobe check does that), so a file half edited still finds its
|
|
103
|
+
* globe. Throws when the file cannot be read or has no usable name.
|
|
104
|
+
*/
|
|
105
|
+
export function globeName(file) {
|
|
106
|
+
let doc;
|
|
107
|
+
try {
|
|
108
|
+
doc = parse(readFileSync(file, 'utf8'));
|
|
109
|
+
} catch (e) {
|
|
110
|
+
throw new Error(`${file}: cannot read it (${e.code ?? e.message.split('\n')[0]})`);
|
|
111
|
+
}
|
|
112
|
+
const name = isMap(doc) ? doc.globe : undefined;
|
|
113
|
+
if (typeof name !== 'string' || !GLOBE_RE.test(name) || GLOBE_KEPT.includes(name))
|
|
114
|
+
throw new Error(`${file}: globe: ${show(name)} is not a usable name (lowercase letters and digits, starting with a letter, at most 31; snowglobe check says more)`);
|
|
115
|
+
return name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check a parsed file and fill in its defaults. baseDir is where relative paths in it
|
|
120
|
+
* (lexicons.publish, a group host's build) are found; without it they are not looked up.
|
|
121
|
+
*/
|
|
122
|
+
export function checkGlobe(doc, { baseDir } = {}) {
|
|
123
|
+
const errors = [];
|
|
124
|
+
const err = (where, msg) => errors.push(`${where}: ${msg}`);
|
|
125
|
+
|
|
126
|
+
if (!isMap(doc)) return { globe: null, errors: ['the file must be a map of settings'] };
|
|
127
|
+
unknownKeys(doc, TOP, '', err);
|
|
128
|
+
|
|
129
|
+
// globe, domain
|
|
130
|
+
if (doc.globe === undefined) err('globe', 'required');
|
|
131
|
+
else if (typeof doc.globe !== 'string' || !GLOBE_RE.test(doc.globe))
|
|
132
|
+
err('globe', `${show(doc.globe)} must be lowercase letters and digits, starting with a letter, at most 31`);
|
|
133
|
+
else if (GLOBE_KEPT.includes(doc.globe)) err('globe', `"${doc.globe}" is a folder snowglobe keeps for itself; pick another name`);
|
|
134
|
+
const domain = doc.domain ?? DEFAULT_DOMAIN;
|
|
135
|
+
const domainOk = typeof domain === 'string' && DOMAIN_RE.test(domain) && domain.length <= NAME_MAX;
|
|
136
|
+
if (!domainOk)
|
|
137
|
+
err('domain', `${show(domain)} must be a domain name, such as ${DEFAULT_DOMAIN}`);
|
|
138
|
+
else {
|
|
139
|
+
const fixed = FIXED_DOMAINS.find(
|
|
140
|
+
({ name, wildcard }) => name === domain || name.endsWith(`.${domain}`) || (wildcard && domain.endsWith(`.${name}`)),
|
|
141
|
+
);
|
|
142
|
+
if (fixed) err('domain', `"${domain}" overlaps ${fixed.name}, which the network serves whatever the domain`);
|
|
143
|
+
}
|
|
144
|
+
// The domain as names are built from it. A refused domain may not be a string at all, and
|
|
145
|
+
// its error is already reported, so the names below are built on a stand-in.
|
|
146
|
+
const D = domainOk ? domain : '<domain>';
|
|
147
|
+
// A generated name must fit in DNS: <name>.<domain>, and a handle <name>.<pds>.<domain>.
|
|
148
|
+
// A domain already refused would only repeat its error here.
|
|
149
|
+
const fits = (name, where) => {
|
|
150
|
+
if (domainOk && name.length > NAME_MAX) err(where, `${name} is longer than ${NAME_MAX} characters`);
|
|
151
|
+
};
|
|
152
|
+
// The network's own names under the domain, the longest of them first.
|
|
153
|
+
fits(`${[...RESERVED].sort((a, b) => b.length - a.length)[0]}.${D}`, 'domain');
|
|
154
|
+
|
|
155
|
+
// Every name that becomes <name>.<domain>, so two of them cannot share one.
|
|
156
|
+
const names = new Map();
|
|
157
|
+
const claim = (name, where) => {
|
|
158
|
+
if (typeof name !== 'string') return;
|
|
159
|
+
fits(`${name}.${D}`, where);
|
|
160
|
+
if (RESERVED.includes(name)) err(where, `"${name}" is taken by the network itself (${name}.${D})`);
|
|
161
|
+
else if (names.has(name)) err(where, `"${name}" is already used by ${names.get(name)}`);
|
|
162
|
+
else names.set(name, where);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// pds
|
|
166
|
+
const pds = [];
|
|
167
|
+
if (doc.pds === undefined) err('pds', 'required: at least one PDS');
|
|
168
|
+
else if (!Array.isArray(doc.pds) || doc.pds.length === 0) err('pds', 'must be a list of at least one PDS');
|
|
169
|
+
else
|
|
170
|
+
doc.pds.forEach((p, i) => {
|
|
171
|
+
const at = `pds[${i}]`;
|
|
172
|
+
if (!isMap(p)) return err(at, 'must be a map with name and image');
|
|
173
|
+
unknownKeys(p, ['name', 'image', 'bare_handles', 'invites'], at, err);
|
|
174
|
+
label(p.name, `${at}.name`, err);
|
|
175
|
+
claim(p.name, `${at}.name`);
|
|
176
|
+
const image = imageRef(p.image, `${at}.image`, err, { required: true });
|
|
177
|
+
if (p.bare_handles !== undefined && typeof p.bare_handles !== 'boolean')
|
|
178
|
+
err(`${at}.bare_handles`, 'must be true or false');
|
|
179
|
+
const invites = p.invites ?? 'open';
|
|
180
|
+
if (invites !== 'open' && invites !== 'required') err(`${at}.invites`, `${show(invites)} must be open or required`);
|
|
181
|
+
pds.push({ name: p.name, image, bare_handles: p.bare_handles === true, invites });
|
|
182
|
+
});
|
|
183
|
+
const bare = pds.filter((p) => p.bare_handles);
|
|
184
|
+
if (bare.length > 1)
|
|
185
|
+
err('pds', `only one PDS can take bare handles (<name>.${D}); ${bare.map((p) => show(p.name)).join(' and ')} all do`);
|
|
186
|
+
const pdsNames = pds.map((p) => p.name).filter((n) => typeof n === 'string');
|
|
187
|
+
|
|
188
|
+
// grouphosts
|
|
189
|
+
const grouphosts = [];
|
|
190
|
+
if (doc.grouphosts !== undefined) {
|
|
191
|
+
if (!Array.isArray(doc.grouphosts)) err('grouphosts', 'must be a list');
|
|
192
|
+
else
|
|
193
|
+
doc.grouphosts.forEach((g, i) => {
|
|
194
|
+
const at = `grouphosts[${i}]`;
|
|
195
|
+
if (!isMap(g)) return err(at, 'must be a map with name and kind');
|
|
196
|
+
unknownKeys(g, ['name', 'kind', 'image', 'build'], at, err);
|
|
197
|
+
label(g.name, `${at}.name`, err);
|
|
198
|
+
claim(g.name, `${at}.name`);
|
|
199
|
+
const known = typeof g.kind === 'string' && Object.hasOwn(GROUPHOST_KINDS, g.kind);
|
|
200
|
+
if (!known)
|
|
201
|
+
err(`${at}.kind`, `${show(g.kind)} is not a kind of group host; known: ${Object.keys(GROUPHOST_KINDS).join(', ')}`);
|
|
202
|
+
if (g.image !== undefined && g.build !== undefined) err(at, 'takes an image or a build folder, not both');
|
|
203
|
+
const image = g.image === undefined ? (known ? GROUPHOST_KINDS[g.kind].image : null) : imageRef(g.image, `${at}.image`, err);
|
|
204
|
+
let build = null;
|
|
205
|
+
if (g.build !== undefined) {
|
|
206
|
+
if (typeof g.build !== 'string' || g.build === '') err(`${at}.build`, 'must be a folder path');
|
|
207
|
+
else {
|
|
208
|
+
build = baseDir ? resolve(baseDir, g.build) : g.build;
|
|
209
|
+
if (baseDir && !isBuildDir(build)) err(`${at}.build`, `${show(g.build)} is not a folder with a Dockerfile (a path from the file's folder)`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
grouphosts.push({ name: g.name, kind: g.kind, image, build });
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// services
|
|
217
|
+
let tap = null;
|
|
218
|
+
const services = { relay: 'auto', jetstream: true, tap: false, slingshot: true, pdsls: true, cdn: false };
|
|
219
|
+
if (doc.services !== undefined) {
|
|
220
|
+
if (!isMap(doc.services)) err('services', 'must be a map');
|
|
221
|
+
else {
|
|
222
|
+
unknownKeys(doc.services, SERVICES, 'services', err);
|
|
223
|
+
for (const k of SERVICES) {
|
|
224
|
+
const v = doc.services[k];
|
|
225
|
+
if (v === undefined) continue;
|
|
226
|
+
if (typeof v === 'boolean' || (k === 'relay' && v === 'auto')) services[k] = v;
|
|
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' : ''}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Without a relay Jetstream and TAP follow one PDS's own firehose, and the others' writes
|
|
235
|
+
// are seen by nothing.
|
|
236
|
+
if (services.relay === 'auto') services.relay = pds.length > 1;
|
|
237
|
+
else if (services.relay === false && pds.length > 1)
|
|
238
|
+
err('services.relay', 'cannot be off with more than one PDS: only one PDS would be followed');
|
|
239
|
+
// Slingshot reads this network's Jetstream.
|
|
240
|
+
if (services.slingshot && !services.jetstream) err('services.slingshot', 'needs jetstream');
|
|
241
|
+
|
|
242
|
+
// lexicons
|
|
243
|
+
const lexicons = { authority: (bare[0] ?? pds[0])?.name, publish: null };
|
|
244
|
+
if (doc.lexicons !== undefined) {
|
|
245
|
+
if (!isMap(doc.lexicons)) err('lexicons', 'must be a map');
|
|
246
|
+
else {
|
|
247
|
+
unknownKeys(doc.lexicons, ['authority', 'publish'], 'lexicons', err);
|
|
248
|
+
if (doc.lexicons.authority !== undefined) {
|
|
249
|
+
if (!pdsNames.includes(doc.lexicons.authority))
|
|
250
|
+
err('lexicons.authority', `${show(doc.lexicons.authority)} is not a PDS in this file (${pdsNames.join(', ')})`);
|
|
251
|
+
lexicons.authority = doc.lexicons.authority;
|
|
252
|
+
}
|
|
253
|
+
if (doc.lexicons.publish !== undefined) {
|
|
254
|
+
if (typeof doc.lexicons.publish !== 'string') err('lexicons.publish', 'must be a folder path');
|
|
255
|
+
else {
|
|
256
|
+
lexicons.publish = doc.lexicons.publish;
|
|
257
|
+
if (baseDir) {
|
|
258
|
+
const dir = resolve(baseDir, doc.lexicons.publish);
|
|
259
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
260
|
+
err('lexicons.publish', `${show(doc.lexicons.publish)} is not a folder next to the file`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The authority's account is made on its PDS like any other, so its handle must fit too.
|
|
268
|
+
const authority = pds.find((p) => p.name === lexicons.authority);
|
|
269
|
+
if (authority)
|
|
270
|
+
fits(authority.bare_handles ? `${AUTHORITY_ACCOUNT}.${D}` : `${AUTHORITY_ACCOUNT}.${authority.name}.${D}`, 'lexicons.authority');
|
|
271
|
+
|
|
272
|
+
// apps (before accounts, so a bare handle meets the apps' handle endings)
|
|
273
|
+
const apps = [];
|
|
274
|
+
if (doc.apps !== undefined) {
|
|
275
|
+
if (!Array.isArray(doc.apps)) err('apps', 'must be a list');
|
|
276
|
+
else {
|
|
277
|
+
const appNames = new Set();
|
|
278
|
+
doc.apps.forEach((a, i) => {
|
|
279
|
+
const at = `apps[${i}]`;
|
|
280
|
+
if (!isMap(a)) return err(at, 'must be a map with name');
|
|
281
|
+
unknownKeys(a, ['name', 'local', 'env', 'hosted'], at, err);
|
|
282
|
+
label(a.name, `${at}.name`, err);
|
|
283
|
+
if (appNames.has(a.name)) err(`${at}.name`, `${show(a.name)} is listed twice`);
|
|
284
|
+
appNames.add(a.name);
|
|
285
|
+
// No prototype, so a setting named __proto__ is a setting like any other and is checked.
|
|
286
|
+
const app = { name: a.name, local: null, env: Object.create(null), hosted: null };
|
|
287
|
+
if (a.local !== undefined) {
|
|
288
|
+
if (!isMap(a.local)) err(`${at}.local`, 'must be a map with port');
|
|
289
|
+
else {
|
|
290
|
+
unknownKeys(a.local, ['port', 'handles'], `${at}.local`, err);
|
|
291
|
+
if (!Number.isInteger(a.local.port) || a.local.port < 1 || a.local.port > 65535)
|
|
292
|
+
err(`${at}.local.port`, `${show(a.local.port)} must be a port number`);
|
|
293
|
+
// An app's handle ending is <ending>.<domain>, the same space as a PDS's name.
|
|
294
|
+
if (a.local.handles !== undefined) {
|
|
295
|
+
label(a.local.handles, `${at}.local.handles`, err);
|
|
296
|
+
claim(a.local.handles, `${at}.local.handles`);
|
|
297
|
+
}
|
|
298
|
+
app.local = { port: a.local.port, handles: a.local.handles ?? null };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// The settings the app is handed: NAME -> a value with ${name} placeholders. The names in
|
|
302
|
+
// them are checked below, once the rest of the file is known.
|
|
303
|
+
if (a.env !== undefined) {
|
|
304
|
+
if (!isMap(a.env)) err(`${at}.env`, 'must be a map of NAME: value');
|
|
305
|
+
else
|
|
306
|
+
for (const [key, v] of Object.entries(a.env)) {
|
|
307
|
+
if (!ENV_NAME_RE.test(key)) err(`${at}.env.${key}`, `${show(key)} is not a setting name (letters, digits and _, not starting with a digit)`);
|
|
308
|
+
else if (!['string', 'number', 'boolean'].includes(typeof v)) err(`${at}.env.${key}`, `${show(v)} must be a string`);
|
|
309
|
+
// A process cannot be given a value holding NUL, and the error says what the value was.
|
|
310
|
+
else if (String(v).includes('\0')) err(`${at}.env.${key}`, 'must not contain a NUL character');
|
|
311
|
+
else app.env[key] = String(v);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (a.hosted !== undefined) {
|
|
315
|
+
if (!isMap(a.hosted)) err(`${at}.hosted`, 'must be a map with service');
|
|
316
|
+
else {
|
|
317
|
+
unknownKeys(a.hosted, ['service', 'link'], `${at}.hosted`, err);
|
|
318
|
+
const port = typeof a.hosted.service === 'string' ? /^[a-z0-9-]+:(\d{1,5})$/.exec(a.hosted.service)?.[1] : null;
|
|
319
|
+
if (!port || +port < 1 || +port > 65535)
|
|
320
|
+
err(`${at}.hosted.service`, `${show(a.hosted.service)} must be <namespace>:<port>`);
|
|
321
|
+
if (a.hosted.link !== undefined && (typeof a.hosted.link !== 'string' || !/^https:\/\/\S+$/.test(a.hosted.link)))
|
|
322
|
+
err(`${at}.hosted.link`, `${show(a.hosted.link)} must be an https URL`);
|
|
323
|
+
app.hosted = { service: a.hosted.service, link: a.hosted.link ?? null };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
apps.push(app);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// accounts: <handle>@<pds>
|
|
332
|
+
const accounts = [];
|
|
333
|
+
if (doc.accounts !== undefined) {
|
|
334
|
+
if (!Array.isArray(doc.accounts)) err('accounts', 'must be a list of <name>@<pds>');
|
|
335
|
+
else {
|
|
336
|
+
const seen = new Set();
|
|
337
|
+
const byName = new Map();
|
|
338
|
+
doc.accounts.forEach((a, i) => {
|
|
339
|
+
const at = `accounts[${i}]`;
|
|
340
|
+
const m = typeof a === 'string' ? /^([^@]+)@([^@]+)$/.exec(a) : null;
|
|
341
|
+
if (!m) return err(at, `${show(a)} must be <name>@<pds>, such as alice@alpha`);
|
|
342
|
+
const [, name, on] = m;
|
|
343
|
+
label(name, at, err);
|
|
344
|
+
const host = pds.find((p) => p.name === on);
|
|
345
|
+
if (!host) return err(at, `"${on}" is not a PDS in this file (${pdsNames.join(', ')})`);
|
|
346
|
+
if (name === AUTHORITY_ACCOUNT) err(at, `"${name}" is the lexicon authority's own account`);
|
|
347
|
+
// A bare handle is <name>.<domain>, the same space as the network's own names.
|
|
348
|
+
if (host.bare_handles && (RESERVED.includes(name) || names.has(name)))
|
|
349
|
+
err(at, `"${name}.${D}" would be the address of ${names.get(name) ?? 'the network itself'}`);
|
|
350
|
+
const handle = host.bare_handles ? `${name}.${D}` : `${name}.${on}.${D}`;
|
|
351
|
+
fits(handle, at);
|
|
352
|
+
if (seen.has(handle)) err(at, `${handle} is listed twice`);
|
|
353
|
+
// Logins are kept by name (ALICE_PASSWORD), and apps[].env names an account by it.
|
|
354
|
+
else if (byName.has(name)) err(at, `"${name}" is already ${byName.get(name)}; an account's name must be unique in the file`);
|
|
355
|
+
seen.add(handle);
|
|
356
|
+
if (!byName.has(name)) byName.set(name, `${a} (accounts[${i}])`);
|
|
357
|
+
accounts.push({ name, pds: on, handle });
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// hosted: read only by the hosted stamp
|
|
363
|
+
const hosted = { base_domain: 'snowglobe.at', ses_region: null };
|
|
364
|
+
if (doc.hosted !== undefined) {
|
|
365
|
+
if (!isMap(doc.hosted)) err('hosted', 'must be a map');
|
|
366
|
+
else {
|
|
367
|
+
unknownKeys(doc.hosted, ['base_domain', 'ses_region'], 'hosted', err);
|
|
368
|
+
if (doc.hosted.base_domain !== undefined) {
|
|
369
|
+
if (typeof doc.hosted.base_domain !== 'string' || !DOMAIN_RE.test(doc.hosted.base_domain))
|
|
370
|
+
err('hosted.base_domain', `${show(doc.hosted.base_domain)} must be a domain name`);
|
|
371
|
+
hosted.base_domain = doc.hosted.base_domain;
|
|
372
|
+
}
|
|
373
|
+
if (doc.hosted.ses_region !== undefined) {
|
|
374
|
+
if (typeof doc.hosted.ses_region !== 'string' || !/^[a-z]{2}-[a-z]+-\d$/.test(doc.hosted.ses_region))
|
|
375
|
+
err('hosted.ses_region', `${show(doc.hosted.ses_region)} must be an AWS region, such as us-east-1`);
|
|
376
|
+
hosted.ses_region = doc.hosted.ses_region;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const globe = { globe: doc.globe, domain, pds, grouphosts, services, tap, lexicons, accounts, apps, hosted };
|
|
382
|
+
// The names in the apps' settings, against the file as it now stands. Left until the rest is sound,
|
|
383
|
+
// or a PDS that failed its own check would be reported again as an unknown name.
|
|
384
|
+
if (!errors.length) for (const [where, msg] of checkSettings(globe)) err(where, msg);
|
|
385
|
+
return { globe: errors.length ? null : globe, errors };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The globe with every group host given where its image comes from: the file's image or build
|
|
390
|
+
* folder, else the folder its kind's environment variable (SG_OPENSOCIAL_BUILD) names,
|
|
391
|
+
* relative to `cwd`. Throws, naming each one and how to give it, when a group host has none or
|
|
392
|
+
* the folder has no Dockerfile. check leaves this out: a hosted globe's file names no source.
|
|
393
|
+
*/
|
|
394
|
+
export function withGroupHostSources(globe, env = process.env, cwd = process.cwd()) {
|
|
395
|
+
const missing = [];
|
|
396
|
+
const grouphosts = globe.grouphosts.map((g, i) => {
|
|
397
|
+
if (g.image || g.build) return g;
|
|
398
|
+
const kind = GROUPHOST_KINDS[g.kind];
|
|
399
|
+
const from = env[kind.buildEnv];
|
|
400
|
+
if (!from) {
|
|
401
|
+
missing.push(`grouphosts[${i}] (${g.name}): no image and no build folder; set ${kind.buildEnv} to ${kind.source}, or give it build: <folder> or image: in the file`);
|
|
402
|
+
return g;
|
|
403
|
+
}
|
|
404
|
+
const build = resolve(cwd, from);
|
|
405
|
+
if (!isBuildDir(build)) missing.push(`grouphosts[${i}] (${g.name}): ${kind.buildEnv}=${from} is not a folder with a Dockerfile`);
|
|
406
|
+
return { ...g, build };
|
|
407
|
+
});
|
|
408
|
+
if (missing.length) throw new Error(missing.join('\n'));
|
|
409
|
+
return { ...globe, grouphosts };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isBuildDir(dir) {
|
|
413
|
+
return existsSync(dir) && statSync(dir).isDirectory() && existsSync(resolve(dir, 'Dockerfile'));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// A short name from IMAGE_NAMES, or an image pinned by tag or digest. :latest and an
|
|
417
|
+
// untagged name move under you, so they are refused.
|
|
418
|
+
function imageRef(v, where, err, { required = false } = {}) {
|
|
419
|
+
if (v === undefined) {
|
|
420
|
+
if (required) err(where, 'required');
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
if (typeof v !== 'string' || v === '') return err(where, 'must be an image name'), null;
|
|
424
|
+
if (Object.hasOwn(IMAGE_NAMES, v)) return IMAGE_NAMES[v];
|
|
425
|
+
const ref = parseImage(v);
|
|
426
|
+
if (!ref) return err(where, `${show(v)} is not an image reference ([registry/]name[:tag][@sha256:<64 hex>])`), null;
|
|
427
|
+
const { tag, digest } = ref;
|
|
428
|
+
if (digest) return v;
|
|
429
|
+
if (!tag) return err(where, `${show(v)} has no tag or digest; pin it, or use one of: ${Object.keys(IMAGE_NAMES).join(', ')}`), null;
|
|
430
|
+
if (tag === 'latest') return err(where, `${show(v)} is :latest, which moves; pin a version or a digest`), null;
|
|
431
|
+
return v;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Split an image reference into its tag and digest, or null when it is not one.
|
|
435
|
+
function parseImage(v) {
|
|
436
|
+
let rest = v;
|
|
437
|
+
let digest = null;
|
|
438
|
+
const at = rest.indexOf('@');
|
|
439
|
+
if (at !== -1) {
|
|
440
|
+
const m = /^([a-z0-9]+):([a-f0-9]+)$/.exec(rest.slice(at + 1));
|
|
441
|
+
if (!m || !Object.hasOwn(DIGEST_HEX, m[1]) || m[2].length !== DIGEST_HEX[m[1]]) return null;
|
|
442
|
+
digest = rest.slice(at + 1);
|
|
443
|
+
rest = rest.slice(0, at);
|
|
444
|
+
}
|
|
445
|
+
let tag = null;
|
|
446
|
+
const colon = rest.lastIndexOf(':');
|
|
447
|
+
if (colon > rest.lastIndexOf('/') && !rest.slice(colon).includes(']')) {
|
|
448
|
+
tag = rest.slice(colon + 1);
|
|
449
|
+
if (!TAG_RE.test(tag)) return null;
|
|
450
|
+
rest = rest.slice(0, colon);
|
|
451
|
+
}
|
|
452
|
+
if (rest.length > IMAGE_NAME_MAX) return null;
|
|
453
|
+
let parts = rest.split('/');
|
|
454
|
+
const first = parts[0];
|
|
455
|
+
if (parts.length > 1 && (/[.:[A-Z]/.test(first) || first === 'localhost')) {
|
|
456
|
+
if (!REGISTRY_RE.test(first)) return null;
|
|
457
|
+
parts = parts.slice(1);
|
|
458
|
+
}
|
|
459
|
+
return parts.every((p) => PATH_RE.test(p)) ? { tag, digest } : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function label(v, where, err) {
|
|
463
|
+
if (typeof v !== 'string' || !LABEL_RE.test(v))
|
|
464
|
+
err(where, `${show(v)} must be one lowercase DNS label (letters, digits, inner hyphens)`);
|
|
465
|
+
}
|
|
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
|
+
|
|
492
|
+
function unknownKeys(map, allowed, where, err) {
|
|
493
|
+
for (const k of Object.keys(map))
|
|
494
|
+
if (!allowed.includes(k)) err(where ? `${where}.${k}` : k, `unknown setting; known here: ${allowed.join(', ')}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A plain YAML mapping. A !!omap or !!set parses to a Map or Set, whose entries the checks
|
|
498
|
+
// below would not see, so it is refused rather than passed unread.
|
|
499
|
+
function isMap(v) {
|
|
500
|
+
if (v === null || typeof v !== 'object') return false;
|
|
501
|
+
const proto = Object.getPrototypeOf(v);
|
|
502
|
+
return proto === Object.prototype || proto === null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// A value as the file wrote it, for an error message. Never throws: YAML aliases can make a
|
|
506
|
+
// value that contains itself.
|
|
507
|
+
function show(v) {
|
|
508
|
+
if (v === undefined) return 'nothing';
|
|
509
|
+
try {
|
|
510
|
+
return JSON.stringify(v) ?? String(v);
|
|
511
|
+
} catch {
|
|
512
|
+
return Array.isArray(v) ? 'a list' : typeof v === 'object' ? 'a map' : typeof v;
|
|
513
|
+
}
|
|
514
|
+
}
|