fedipod-server 0.6.0 → 0.8.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/README.md CHANGED
@@ -10,10 +10,32 @@ client at the pod's own address. Signing up is the only way an account is
10
10
  made, and opting out is the only way one ends.
11
11
 
12
12
  With nothing configured beyond the defaults, installing the component changes
13
- nothing about how the server serves pods.
13
+ nothing about how the server serves pods. All pods, whether or not they opt-in
14
+ to being a Fediverse account, behave as Solid pods.
14
15
 
15
16
  ![A pod and its agent, both inside one Solid server](fedipod-server.svg)
16
17
 
18
+ ## Requirements
19
+
20
+ A pod here becomes an address other Fediverse servers talk to and a Mastodon
21
+ app connects to. That asks things of the server it lives on. Check these
22
+ before installing: a server that does not meet them cannot offer accounts, and
23
+ sign-up refuses rather than half-working.
24
+
25
+ - **Community Solid Server 7.**
26
+ - **Pods on subdomains.** Each account answers on its own pod's address, so
27
+ every pod needs a host to itself. A server that puts its pods on paths of
28
+ one shared host cannot offer accounts at all.
29
+ - **A single worker.** Run the server as one copy of itself — `--workers 1`,
30
+ which is the default. With more, it still serves pods normally, but no pod
31
+ can be an account: sign-up is refused, and an account set up earlier goes on
32
+ posting and receiving while no longer answering its owner's apps.
33
+ - **Storage that survives a restart.** Sign-ups are recorded in the server's
34
+ own storage. On a memory backend every account is forgotten when the server
35
+ stops.
36
+ - **A directory on disk.** Each account keeps its signing key and its door
37
+ secret there. Sign-up will not start without one.
38
+
17
39
  ## Install
18
40
 
19
41
  ```
@@ -129,11 +151,6 @@ belong to the identity, so pod resources at those names — a container called
129
151
  They stay in the pod and in its listings. Every other path is the pod, exactly
130
152
  as before.
131
153
 
132
- **Run one worker.** A delivery is picked up the moment it lands only in a
133
- single-worker server. With `--workers` above one the inbox is swept on the
134
- timer instead, and the server says so at startup. Sign-up requires a
135
- single worker outright, and says so when refused.
136
-
137
154
  ## How sign-up works
138
155
 
139
156
  A pod owner opens the `/run` page, signs in with their pod,
@@ -144,8 +161,11 @@ POST /api/agent
144
161
  {"action": "opt-in", "podBase": "https://mei.example.org/"}
145
162
  ```
146
163
 
147
- The request carries a Solid-OIDC token; the WebID it proves must live under
148
- the pod being claimed. The reply carries the identity's door secret, shown
164
+ The request carries a Solid-OIDC token. Where the pod's server names an owner,
165
+ the token must prove that owner. Where it names none, the WebID the token
166
+ proves must live under the pod being claimed.
167
+
168
+ The reply carries the identity's door secret, shown
149
169
  that once and never again — losing it is not fatal, because opting in again
150
170
  mints a fresh one and retires the old, with no restart and no dropped
151
171
  connections. `{"action": "opt-out", "podBase": "https://mei.example.org/"}`,
@@ -49,6 +49,17 @@
49
49
  }
50
50
  ]
51
51
  },
52
+ {
53
+ "comment": "Claiming the identities' routes in every process that answers requests. The identities themselves run in one process — with workers, the primary — but a worker that had claimed nothing would hand an identity's client API to plain pod serving instead of saying it cannot be reached.",
54
+ "@id": "urn:solid-server:default:WorkerParallelInitializer",
55
+ "@type": "ParallelHandler",
56
+ "handlers": [
57
+ {
58
+ "@type": "InitializableHandler",
59
+ "initializable": { "@id": "urn:fedipod:server:Handler" }
60
+ }
61
+ ]
62
+ },
52
63
  {
53
64
  "comment": "Insert the handler into the routing waterfall just before the LDP catch-all, so it claims only the front routes and everything else stays normal CSS. OverrideListInsertBefore is available in componentsjs 5.x (which CSS 7 uses).",
54
65
  "@type": "Override",
package/dist/claims.js CHANGED
@@ -6,11 +6,12 @@
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.claims = claims;
8
8
  exports.agentClaims = agentClaims;
9
- const FRONT_PATHS = new Set(['/', '/signup', '/new-account', '/run',
9
+ const FRONT_PATHS = new Set(['/', '/signup', '/new-account', '/run', '/admin',
10
10
  '/.well-known/webfinger', '/api/handle', '/api/attach', '/api/agent',
11
- // What the two pages load: the sign-in library both use, and the installer
12
- // the signup page hands every new user.
13
- '/solid-client-authn.bundle.js', '/install']);
11
+ '/api/roster', '/api/revoke',
12
+ // What the pages load: the sign-in library the /run and /admin pages use, and
13
+ // the installer the signup page hands every new user.
14
+ '/solid-oidc-client.js', '/install']);
14
15
  function claims(input, frontHost) {
15
16
  if (!input.host || !frontHost)
16
17
  return false;
@@ -32,6 +32,9 @@
32
32
  "args_runPage": {
33
33
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_runPage"
34
34
  },
35
+ "args_adminPage": {
36
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_adminPage"
37
+ },
35
38
  "args_agentDataDir": {
36
39
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_agentDataDir"
37
40
  },
@@ -83,6 +86,9 @@
83
86
  "runPage": {
84
87
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_runPage"
85
88
  },
89
+ "adminPage": {
90
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_adminPage"
91
+ },
86
92
  "agentDataDir": {
87
93
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_agentDataDir"
88
94
  },
@@ -19,6 +19,17 @@ export interface Directory {
19
19
  lookup(handle: string): Promise<DirectoryRecord | null>;
20
20
  putDirectory(handle: string, record: DirectoryRecord): Promise<void>;
21
21
  }
22
+ /**
23
+ * The row an identity this server runs should have in the door's directory,
24
+ * or null to leave what is already there alone.
25
+ *
26
+ * A row an owner made by attaching is theirs, and is never touched. A row this
27
+ * server wrote for this same pod is corrected when it still names the pod root
28
+ * rather than the identity's own tree — the place the door writes deliveries
29
+ * and the agent watches. The secret rides across the correction, because a
30
+ * gateway holding it has to go on working.
31
+ */
32
+ export declare function frontRow(existing: DirectoryRecord | null, next: DirectoryRecord, podBase: string): DirectoryRecord | null;
22
33
  export declare function makeDirectory(io: IO, containerUrl: string): Directory;
23
34
  /** A pod whose owner opted in at runtime. The door secret is never in the row. */
24
35
  export interface AgentRegistryRecord {
package/dist/directory.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // read and written through a minimal IO shape { read, write } so it tests
4
4
  // without a running CSS. store-css.ts supplies the real IO over a ResourceStore.
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.frontRow = frontRow;
6
7
  exports.makeDirectory = makeDirectory;
7
8
  exports.makeAgentRegistry = makeAgentRegistry;
8
9
  exports.makeStorePodPut = makeStorePodPut;
@@ -31,6 +32,24 @@ function jsonTable(io, containerUrl) {
31
32
  },
32
33
  };
33
34
  }
35
+ /**
36
+ * The row an identity this server runs should have in the door's directory,
37
+ * or null to leave what is already there alone.
38
+ *
39
+ * A row an owner made by attaching is theirs, and is never touched. A row this
40
+ * server wrote for this same pod is corrected when it still names the pod root
41
+ * rather than the identity's own tree — the place the door writes deliveries
42
+ * and the agent watches. The secret rides across the correction, because a
43
+ * gateway holding it has to go on working.
44
+ */
45
+ function frontRow(existing, next, podBase) {
46
+ if (!existing)
47
+ return next;
48
+ const ours = existing.inboxOnly === true && existing.podHome === podBase;
49
+ if (!ours || existing.podHome === next.podHome)
50
+ return null;
51
+ return { ...existing, podHome: next.podHome, actorUrl: next.actorUrl };
52
+ }
34
53
  function makeDirectory(io, containerUrl) {
35
54
  const table = jsonTable(io, containerUrl);
36
55
  return {
package/dist/handler.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface FediPodServerArgs {
18
18
  signupPage?: string;
19
19
  /** The run-your-identity page HTML served at /run. */
20
20
  runPage?: string;
21
+ /** The accounts-roster page HTML served at /admin. */
22
+ adminPage?: string;
21
23
  /** Directory holding each agent identity's signing key and log. Required when runtime opt-in is on. */
22
24
  agentDataDir?: string;
23
25
  /** Path from a pod's base to the owner's WebID. */
@@ -41,6 +43,9 @@ export interface FediPodServerArgs {
41
43
  interface EmbeddedIdentity {
42
44
  handle: string;
43
45
  host: string;
46
+ /** Where the identity's own tree begins on its pod, and the actor inside it. */
47
+ podHome: string;
48
+ actorUrl: string;
44
49
  surface: {
45
50
  handler: (req: unknown, res: unknown) => Promise<void>;
46
51
  streaming?: unknown;
@@ -91,11 +96,22 @@ export declare class FediPodServerHandler extends HttpHandler implements Initial
91
96
  * When this server is also the door, give a running identity a
92
97
  * @handle@<frontHost> address: one inbox-only directory row so the
93
98
  * shared-domain handle resolves and the door can take verified delivery for
94
- * it. Written once a manual attach or an earlier boot wins. The identity
95
- * keeps its own actor ids on the pod; nothing is moved.
99
+ * it. The row names the identity's own tree, which is where its inbox and
100
+ * its actor are the door writes deliveries there and the agent watches
101
+ * that container. A row written by an attach belongs to its owner and is
102
+ * left alone; one this server wrote itself is corrected. The identity keeps
103
+ * its own ids on the pod; nothing is moved.
96
104
  */
97
105
  private frontIdentity;
98
106
  canHandle({ request }: HttpHandlerInput): Promise<void>;
107
+ /**
108
+ * Whether identities run in this process. They run in one: the lease admits
109
+ * a single drainer, and the state each one holds is in memory. With workers,
110
+ * that process is the primary — which serves no requests, so every process
111
+ * still has to know which hosts belong to an identity even though only one
112
+ * of them can answer for it.
113
+ */
114
+ private runsIdentities;
99
115
  /** The running identity answering on a host, if it has finished starting. */
100
116
  surfaceFor(host?: string): EmbeddedIdentity | undefined;
101
117
  /**
package/dist/handler.js CHANGED
@@ -134,11 +134,12 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
134
134
  async initialize() {
135
135
  if (!this.registry)
136
136
  return;
137
+ const runs = this.runsIdentities();
137
138
  // The stock CSS CLI installs no signal handlers, so a SIGTERM (systemd
138
139
  // stop, docker stop, Ctrl+C) killed the process with agent state
139
140
  // unflushed and the lease held for its whole TTL. Flush first, bounded,
140
141
  // then re-raise so the process still dies the way it was asked to.
141
- if (!this.onSignal) {
142
+ if (runs && !this.onSignal) {
142
143
  this.onSignal = (signal) => {
143
144
  const timeout = new Promise((resolve) => { setTimeout(resolve, 5_000).unref?.(); });
144
145
  void Promise.race([this.finalize(), timeout]).then(() => {
@@ -148,13 +149,14 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
148
149
  process.once('SIGTERM', this.onSignal);
149
150
  process.once('SIGINT', this.onSignal);
150
151
  }
151
- // The agent runs in the primary process, and a write made by a worker
152
- // raises its change event there so with workers the inbox is swept on the
153
- // timer rather than the moment a delivery lands. Everything still works; it
154
- // is just slower, and worth saying rather than leaving to be discovered.
155
- if (this.args.clusterManager && !this.args.clusterManager.isSingleThreaded()) {
156
- this.logger.warn('FediPod agent is running in a multi-worker server: deliveries are picked up by the '
157
- + 'inbox sweep instead of as they arrive. Run with --workers 1 for immediate delivery.');
152
+ // Identities run in the primary, and requests are answered by the workers,
153
+ // so with more than one worker an identity federates but cannot be reached:
154
+ // its client API, its owner pages and its live feed are in a process no
155
+ // request arrives at. Said once, by the process that has them.
156
+ if (runs && this.args.clusterManager && !this.args.clusterManager.isSingleThreaded()) {
157
+ this.logger.warn('FediPod is running in a multi-worker server. Deliveries are picked up by the inbox '
158
+ + 'sweep rather than as they land, and no identity can answer its client API, its live feed or its '
159
+ + "owner's pages while requests are served by other processes. Run with --workers 1.");
158
160
  }
159
161
  // Awaited, and BEFORE the server listens: a pod whose owner opted in must
160
162
  // have its routes claimed from the first request after a restart, never
@@ -184,10 +186,16 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
184
186
  catch (e) {
185
187
  this.logger.error(`could not read the opt-in registry — opted-in identities are absent this boot: ${e.message}`);
186
188
  }
187
- if (pods.length > 0)
188
- this.logger.info(`FediPod agent enabled for ${pods.length} opted-in pod(s)`);
189
- for (const pod of pods)
190
- void this.startIdentity(pod);
189
+ if (pods.length > 0) {
190
+ this.logger.info(runs
191
+ ? `FediPod agent enabled for ${pods.length} opted-in pod(s)`
192
+ : `FediPod claimed the routes of ${pods.length} opted-in pod(s); they are run elsewhere`);
193
+ }
194
+ // Claimed everywhere, run in one place. A process that does not run them
195
+ // must still not let a pod answer for a path that belongs to an identity.
196
+ if (runs)
197
+ for (const pod of pods)
198
+ void this.startIdentity(pod);
191
199
  }
192
200
  /** Stop every identity: timers cleared, state written, lease let go. */
193
201
  async finalize() {
@@ -267,22 +275,29 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
267
275
  * When this server is also the door, give a running identity a
268
276
  * @handle@<frontHost> address: one inbox-only directory row so the
269
277
  * shared-domain handle resolves and the door can take verified delivery for
270
- * it. Written once a manual attach or an earlier boot wins. The identity
271
- * keeps its own actor ids on the pod; nothing is moved.
278
+ * it. The row names the identity's own tree, which is where its inbox and
279
+ * its actor are the door writes deliveries there and the agent watches
280
+ * that container. A row written by an attach belongs to its owner and is
281
+ * left alone; one this server wrote itself is corrected. The identity keeps
282
+ * its own ids on the pod; nothing is moved.
272
283
  */
273
284
  async frontIdentity(podBase, identity) {
274
- const { handle } = identity;
285
+ const { handle, podHome, actorUrl } = identity;
275
286
  try {
276
- if (await this.dir.lookup(handle))
277
- return;
278
- const kind = identity.agent?.store?.getConfig?.()?.kind === 'group' ? 'group' : 'person';
279
- await this.dir.putDirectory(handle, {
280
- handle, podHome: podBase, actorUrl: `${podBase}ap/actor`, kind,
287
+ const existing = await this.dir.lookup(handle);
288
+ const row = (0, directory_1.frontRow)(existing, {
289
+ handle, podHome, actorUrl,
290
+ kind: identity.agent?.store?.getConfig?.()?.kind === 'group' ? 'group' : 'person',
281
291
  gatewayWebId: this.args.gatewayWebId ?? null,
282
292
  hmacSecret: (0, node_crypto_1.randomBytes)(32).toString('base64'),
283
293
  inboxOnly: true,
284
- });
285
- this.logger.info(`FediPod: @${handle}@${this.frontHost} now resolves to ${podBase}`);
294
+ }, podBase);
295
+ if (!row)
296
+ return;
297
+ await this.dir.putDirectory(handle, row);
298
+ this.logger.info(existing
299
+ ? `FediPod: the door's record of @${handle} now names ${podHome}`
300
+ : `FediPod: @${handle}@${this.frontHost} now resolves to ${podBase}`);
286
301
  }
287
302
  catch (e) {
288
303
  this.logger.warn(`FediPod: could not front @${handle}: ${e.message}`);
@@ -300,6 +315,17 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
300
315
  return;
301
316
  throw new Error('not a gateway route'); // reject → CSS's LDP handler takes it
302
317
  }
318
+ /**
319
+ * Whether identities run in this process. They run in one: the lease admits
320
+ * a single drainer, and the state each one holds is in memory. With workers,
321
+ * that process is the primary — which serves no requests, so every process
322
+ * still has to know which hosts belong to an identity even though only one
323
+ * of them can answer for it.
324
+ */
325
+ runsIdentities() {
326
+ const cluster = this.args.clusterManager;
327
+ return !cluster || cluster.isSingleThreaded() || cluster.isPrimary();
328
+ }
303
329
  /** The running identity answering on a host, if it has finished starting. */
304
330
  surfaceFor(host) {
305
331
  return this.surfaces.get(String(host ?? '').toLowerCase());
@@ -379,8 +405,16 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
379
405
  if (this.agentHosts.has(host)) {
380
406
  const identity = this.surfaces.get(host);
381
407
  if (!identity) {
382
- // Claimed, but its identity is not up yet. Saying so is better than
383
- // letting the pod answer for a route that is about to stop being pod.
408
+ // Claimed, but nothing here can answer for it. Saying which of the two
409
+ // reasons it is beats letting the pod answer for a route that is not
410
+ // the pod's, and beats telling a client to try again when trying again
411
+ // will reach another process just as unable to help.
412
+ if (!this.runsIdentities()) {
413
+ response.writeHead(503, { 'content-type': 'application/json' });
414
+ response.end(JSON.stringify({ error: 'this identity is not reachable on a server running more '
415
+ + 'than one worker: it runs in the process that serves no requests. Run with --workers 1.' }));
416
+ return;
417
+ }
384
418
  response.writeHead(503, { 'content-type': 'application/json', 'retry-after': '5' });
385
419
  response.end(JSON.stringify({ error: 'this identity is still starting' }));
386
420
  return;
@@ -406,9 +440,11 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
406
440
  offersPods: !!this.args.offersPods,
407
441
  signupPage: this.args.signupPage || webFile('new-account.html'),
408
442
  runPage: this.args.runPage || webFile('run.html'),
409
- // Both pages load the sign-in library, and the signup page hands out the
410
- // installer command; without these the pages render but cannot be used.
411
- authBundle: webFile('solid-client-authn.bundle.js'),
443
+ adminPage: this.args.adminPage || webFile('admin.html'),
444
+ // The /run and /admin pages load the sign-in library, and the signup page
445
+ // hands out the installer command; without these the pages render but
446
+ // cannot be used.
447
+ authBundle: webFile('solid-oidc-client.js'),
412
448
  installScript: webFile('install.sh'),
413
449
  lookup: (h) => this.dir.lookup(h),
414
450
  putDirectory: (h, rec) => this.dir.putDirectory(h, rec),
@@ -95,6 +95,19 @@
95
95
  },
96
96
  "comment": "The run-your-identity page HTML served at /run."
97
97
  },
98
+ {
99
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_adminPage",
100
+ "range": {
101
+ "@type": "ParameterRangeUnion",
102
+ "parameterRangeElements": [
103
+ "xsd:string",
104
+ {
105
+ "@type": "ParameterRangeUndefined"
106
+ }
107
+ ]
108
+ },
109
+ "comment": "The accounts-roster page HTML served at /admin."
110
+ },
98
111
  {
99
112
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_agentDataDir",
100
113
  "range": {
@@ -311,6 +324,10 @@
311
324
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_canHandle",
312
325
  "memberFieldName": "canHandle"
313
326
  },
327
+ {
328
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_runsIdentities",
329
+ "memberFieldName": "runsIdentities"
330
+ },
314
331
  {
315
332
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_surfaceFor",
316
333
  "memberFieldName": "surfaceFor"
@@ -380,6 +397,12 @@
380
397
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_runPage"
381
398
  }
382
399
  },
400
+ {
401
+ "keyRaw": "adminPage",
402
+ "value": {
403
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler_args_adminPage"
404
+ }
405
+ },
383
406
  {
384
407
  "keyRaw": "agentDataDir",
385
408
  "value": {
@@ -476,6 +499,10 @@
476
499
  "@id": "fps:dist/handler.jsonld#FediPodServerArgs__member_runPage",
477
500
  "memberFieldName": "runPage"
478
501
  },
502
+ {
503
+ "@id": "fps:dist/handler.jsonld#FediPodServerArgs__member_adminPage",
504
+ "memberFieldName": "adminPage"
505
+ },
479
506
  {
480
507
  "@id": "fps:dist/handler.jsonld#FediPodServerArgs__member_agentDataDir",
481
508
  "memberFieldName": "agentDataDir"