fedipod 1.36.6 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
@@ -0,0 +1,101 @@
1
+ // type-index.mjs — an account's place, recorded in the person's public type
2
+ // index: the list Solid apps read to find what kind of thing lives where.
3
+ //
4
+ // The record is a registration saying there is an instance of `as:Actor` at the
5
+ // account's actor on the pod:
6
+ //
7
+ // <#actor-…> a solid:TypeRegistration;
8
+ // solid:forClass as:Actor;
9
+ // solid:instance <…/ap/actor>.
10
+ //
11
+ // The instance is the actor at the pod, not a gateway's address for it, so the
12
+ // place can be read off it. It is also how the account is found again.
13
+ //
14
+ // Every write goes through the transport's checked writers: valid RDF, to the
15
+ // profile or a document its seeAlso names, or nothing.
16
+
17
+ const SOLID = 'http://www.w3.org/ns/solid/terms#';
18
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
19
+ const AS_ACTOR = 'https://www.w3.org/ns/activitystreams#Actor';
20
+ const PUBLIC_READ = ['Read'];
21
+
22
+ /** Where a new public type index goes when a person has none. */
23
+ export const newIndexUrl = (podBase) => `${podBase}settings/publicTypeIndex.ttl`;
24
+
25
+ /** The person's public type index, or null when their profile names none. */
26
+ export async function findPublicIndex(pod, podBase) {
27
+ const docs = await pod.profileDocs(podBase);
28
+ return pod.webIdValues(docs, SOLID + 'publicTypeIndex')[0] || null;
29
+ }
30
+
31
+ /** Every actor the index registers, as the instances it names. */
32
+ export async function actorsIn(pod, indexUrl) {
33
+ const g = await pod.readRdf(indexUrl).catch(() => null);
34
+ if (!g) return [];
35
+ const out = [];
36
+ for (const reg of g.each(null, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) {
37
+ for (const inst of g.each(reg, pod.sym(SOLID + 'instance'), null)) out.push(inst.value);
38
+ }
39
+ return [...new Set(out)];
40
+ }
41
+
42
+ // One registration per actor, named by the actor, so registering again finds
43
+ // the same one.
44
+ function regFor(indexUrl, actorUrl) {
45
+ let h = 0;
46
+ for (const c of actorUrl) h = (h * 31 + c.charCodeAt(0)) >>> 0;
47
+ return `${indexUrl}#actor-${h.toString(36)}`;
48
+ }
49
+
50
+ /** Add the account's registration, unless the index already has one for it. */
51
+ export async function register(pod, indexUrl, actorUrl) {
52
+ const g = await pod.readRdf(indexUrl);
53
+ if (!g) throw new Error(`no type index at ${indexUrl}`);
54
+ if ((await actorsIn(pod, indexUrl)).includes(actorUrl)) return false;
55
+ const reg = pod.sym(regFor(indexUrl, actorUrl));
56
+ const status = await pod.writeRdfChecked(indexUrl, g, {
57
+ inserts: [
58
+ [reg, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeRegistration')],
59
+ [reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR)],
60
+ [reg, pod.sym(SOLID + 'instance'), pod.sym(actorUrl)],
61
+ ],
62
+ mustDescribe: indexUrl,
63
+ });
64
+ if (status >= 300) throw new Error(`the type index at ${indexUrl} did not take the registration (${status})`);
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Make a public type index and name it from the profile. Only ever called
70
+ * after the person has said yes. An index already at the usual place is kept,
71
+ * not overwritten; it is only named.
72
+ */
73
+ export async function createPublicIndex(pod, podBase) {
74
+ const url = newIndexUrl(podBase);
75
+ const doc = pod.sym(url);
76
+ const existing = await pod.readRdf(url).catch(() => null);
77
+ if (!existing) {
78
+ const status = await pod.writeRdfChecked(url, null, {
79
+ inserts: [[doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeIndex')],
80
+ [doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'ListedDocument')]],
81
+ mustDescribe: url,
82
+ });
83
+ if (status >= 300) throw new Error(`could not make a type index at ${url} (${status})`);
84
+ }
85
+ // Public, as the profile is: an app finds your things by reading it.
86
+ await pod.setAcl(url, PUBLIC_READ);
87
+ await pod.writeAboutWebId(podBase, {
88
+ inserts: [[pod.sym(pod.webId), pod.sym(SOLID + 'publicTypeIndex'), doc]],
89
+ });
90
+ return url;
91
+ }
92
+
93
+ /**
94
+ * The actors this person's index says live in this pod. Empty when there is no
95
+ * index or nothing registered.
96
+ */
97
+ export async function registeredActors(pod, podBase) {
98
+ const index = await findPublicIndex(pod, podBase).catch(() => null);
99
+ if (!index) return [];
100
+ return (await actorsIn(pod, index)).filter(a => a.startsWith(podBase) && a.endsWith('ap/actor'));
101
+ }
package/lib/pod/urls.mjs CHANGED
@@ -46,6 +46,12 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
46
46
  pendingFollowers: face + 'ap/private/pending-followers',
47
47
  pendingFollowing: face + 'ap/private/pending-following',
48
48
  blocked: face + 'ap/private/blocked',
49
+ // The outbox as its owner reads it (every message, §5.1) and what the
50
+ // owner has liked (§5.5): the owner's alone, so they sit here too — and
51
+ // at the pod's own address even for a fronted identity, because the owner
52
+ // reads them there, with a credential the pod checks.
53
+ ownOutbox: home + 'ap/private/outbox',
54
+ liked: home + 'ap/private/liked',
49
55
  profileHtml: face + 'ap/profile.html',
50
56
  // Media stays on the pod even when fronted: attachment urls are not
51
57
  // identity-checked by remotes, and proxying blobs would be pure cost.
@@ -45,10 +45,10 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
45
45
  * secret is moved into the pod and the host's copy removed, so the owner's
46
46
  * existing door link goes on working.
47
47
  */
48
- export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
48
+ export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, root = 'fedipod/', log = () => {} } = {}) {
49
49
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
50
- // Under the identity's own tree (fedipod/), where the gate reads it back.
51
- const url = apUrls(base, 'fedipod/').state + 'door-secret.json';
50
+ // Under the identity's own tree, where the gate reads it back.
51
+ const url = apUrls(base, root).state + 'door-secret.json';
52
52
  const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
53
53
 
54
54
  if (!rotate) {
@@ -125,7 +125,7 @@ export function handleFor(podBase) {
125
125
  * pod does not travel to, so handing the pod over hands over an identity that
126
126
  * cannot sign. An identity set up before this gets the mode added.
127
127
  */
128
- function ensureCredential(home, { podBase, webId }) {
128
+ function ensureCredential(home, { podBase, webId, root = 'fedipod/' }) {
129
129
  const file = path.join(home, 'credential.json');
130
130
  try {
131
131
  const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -138,7 +138,7 @@ function ensureCredential(home, { podBase, webId }) {
138
138
  webId,
139
139
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
140
140
  createdAt: new Date().toISOString(),
141
- root: 'fedipod/',
141
+ root,
142
142
  keysMode: 'pod',
143
143
  };
144
144
  writeJsonAtomic(file, rec, { mode: 0o600 });
@@ -269,6 +269,7 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
269
269
  */
270
270
  export async function startEmbeddedAgent({
271
271
  podBase,
272
+ root = 'fedipod/',
272
273
  dataDir,
273
274
  session,
274
275
  resourceStore = null,
@@ -285,7 +286,7 @@ export async function startEmbeddedAgent({
285
286
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
286
287
 
287
288
  const webId = base + webIdSuffix.replace(/^\//u, '');
288
- const cred = ensureCredential(home, { podBase: base, webId });
289
+ const cred = ensureCredential(home, { podBase: base, webId, root });
289
290
 
290
291
  const agent = new Agent({ home, log });
291
292
  agent.log = log;
@@ -1,4 +1,4 @@
1
- # fediverse-account
1
+ # fediverse-session
2
2
 
3
3
  The library lets an app take a Fediverse handle or a WebID and act as that
4
4
  account.
@@ -51,23 +51,23 @@ the profile that comes back.
51
51
  From a CDN, with nothing to install:
52
52
 
53
53
  ```js
54
- import { fediAccount } from 'https://cdn.jsdelivr.net/npm/fediverse-account@0.1.0/fedi-account.mjs';
54
+ import { fediAccount } from 'https://cdn.jsdelivr.net/npm/fediverse-session@0.2.1/fedi-account.mjs';
55
55
  ```
56
56
 
57
57
  Or from npm:
58
58
 
59
59
  ```bash
60
- npm install fediverse-account
60
+ npm install fediverse-session
61
61
  ```
62
62
 
63
63
  ```js
64
- import { fediAccount } from 'fediverse-account';
64
+ import { fediAccount } from 'fediverse-session';
65
65
  ```
66
66
 
67
67
  ## Use
68
68
 
69
69
  ```js
70
- import { fediAccount } from 'fediverse-account';
70
+ import { fediAccount } from 'fediverse-session';
71
71
  const accounts = fediAccount({ dbName: 'my-app', clientName: 'My app' });
72
72
 
73
73
  await accounts.resume(); // on every page load
@@ -39,7 +39,7 @@
39
39
  <li><strong>Other Fediverse handle</strong> — <code>@you@mastodon.social</code> — signs in at your server</li>
40
40
  <li><strong>A WebID</strong> — <code>https://you.solidcommunity.net/profile/card#me</code> — signs in at your Solid pod</li>
41
41
  </ul>
42
- <p>This demo is created with the <a href="https://github.com/jeff-zucker/FediPod/tree/main/lib/session">fediverse-account</a> library. Once signed in, the demo shows you your profile. The library itself returns an account session with methods to post, reply, like, etc.</p>
42
+ <p>This demo is created with the <a href="https://github.com/jeff-zucker/FediPod/tree/main/lib/session">fediverse-session</a> library. Once signed in, the demo shows you your profile. The library itself returns an account session with methods to post, reply, like, etc.</p>
43
43
  <form id="form">
44
44
  <label>Your address
45
45
  <input id="address" name="address" type="text" autocomplete="username" placeholder="@you@your.server" required>
@@ -60,6 +60,14 @@ export function fediAccount({
60
60
  return body;
61
61
  };
62
62
 
63
+ // A collection, or a page of one, may be named by its address or given
64
+ // whole in place (ActivityPub allows either); the whole one is used as it
65
+ // is, an address is fetched.
66
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id || null);
67
+ const asDoc = async (v) => (v && typeof v === 'object'
68
+ ? (v.type ? v : await asDoc(v.id))
69
+ : (typeof v === 'string' ? await json(v, { headers: { accept: 'application/activity+json' } }) : null));
70
+
63
71
  // Only loaded when `outbox({ rdf: true })` is actually called, so nobody
64
72
  // pays for a JSON-LD parser just by importing this file. Not in
65
73
  // `dependencies` — the app supplies "jsonld" if it wants this flag to work.
@@ -76,15 +84,16 @@ export function fediAccount({
76
84
  if (!actorUrl) return null;
77
85
  const jsonld = await loadJsonLd();
78
86
  const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
79
- let pageUrl = actorDoc?.outbox ? (await json(actorDoc.outbox, { headers: { accept: 'application/activity+json' } }))?.first : null;
87
+ const head = await asDoc(actorDoc?.outbox);
88
+ let page = head?.first ?? null;
80
89
  const quads = []; const seen = new Set(); let collected = 0;
81
- while (pageUrl && collected < limit && !seen.has(pageUrl)) {
82
- seen.add(pageUrl);
83
- const page = await json(pageUrl, { headers: { accept: 'application/activity+json' } });
84
- if (!page) break;
85
- quads.push(...(await jsonld.toRDF(page)));
86
- collected += (page.orderedItems || []).length;
87
- pageUrl = page.next || null;
90
+ while (page && collected < limit && !seen.has(idOf(page) || page)) {
91
+ seen.add(idOf(page) || page);
92
+ const doc = await asDoc(page);
93
+ if (!doc) break;
94
+ quads.push(...(await jsonld.toRDF(doc)));
95
+ collected += (doc.orderedItems || []).length;
96
+ page = doc.next ?? null;
88
97
  }
89
98
  return quads;
90
99
  };
@@ -226,7 +235,7 @@ export function fediAccount({
226
235
  let actor = p.actor || pick(me, FOAF_ACCOUNT);
227
236
  let door = pick(me, AS_OUTBOX);
228
237
  const doc = actor ? await json(actor, { headers: { accept: 'application/activity+json' } }) : null;
229
- if (!door && doc?.outbox) door = doc.outbox;
238
+ if (!door && doc?.outbox) door = idOf(doc.outbox);
230
239
  let root = p.root || null;
231
240
  if (!root && doc?.preferredUsername && actor) {
232
241
  const { aliases } = await webfinger(doc.preferredUsername, new URL(actor).host).catch(() => ({ aliases: [] }));
@@ -340,7 +349,7 @@ export function fediAccount({
340
349
  fetch: session.fetch,
341
350
  async profile() {
342
351
  const doc = facts.actor ? await json(facts.actor, { headers: { accept: 'application/activity+json' } }) : null;
343
- const count = async (url) => (url ? (await json(url, { headers: { accept: 'application/activity+json' } }))?.totalItems ?? null : null);
352
+ const count = async (v) => (await asDoc(v))?.totalItems ?? null;
344
353
  return { handle: facts.handle, name: doc?.name || null, url: doc?.url || null, avatar: doc?.icon?.url || (typeof doc?.icon === 'string' ? doc.icon : null), bio: doc?.summary || '',
345
354
  followers: await count(doc?.followers), following: await count(doc?.following), posts: await count(doc?.outbox), actor: facts.actor, webId: facts.webId };
346
355
  },
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "fediverse-account",
3
- "version": "0.2.0",
2
+ "name": "fediverse-session",
3
+ "version": "0.2.1",
4
4
  "description": "Type a Fediverse handle or a WebID and get back an account you can act with: post, read its timeline, follow, reply, favourite, boost. Works out whether the account lives on a Mastodon-family server or on a Solid pod through FediPod, sends the person to sign in there, brings them back to where they were, and speaks to whichever it is.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.36.6",
3
+ "version": "1.40.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "install-service": "node bin/fedipod.mjs install-service",
39
39
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
40
40
  "prepublishOnly": "npm test",
41
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test packages/fedipod-bb/test/*.test.mjs && node scripts/check-sizes.mjs",
41
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test packages/fedipod-bb/test/*.test.mjs && node scripts/check-sizes.mjs",
42
42
  "verify-ui": "node scripts/verify-ui.mjs",
43
43
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
44
44
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
package/run-agent.mjs CHANGED
@@ -48,6 +48,7 @@ import { AcctFeed } from './lib/connections/acctfeed.mjs';
48
48
  import { BskyFeed } from './lib/connections/bskyfeed.mjs';
49
49
  import { BskyGroup } from './lib/connections/bskygroup.mjs';
50
50
  import { Lease } from './lib/core/lease.mjs';
51
+ import { publishDue } from './lib/core/scheduled.mjs';
51
52
  import { startAdmin } from './lib/device/admin/index.mjs';
52
53
  import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
53
54
  import { pendingSteps } from './lib/device/migrate.mjs';
@@ -552,6 +553,8 @@ export class Agent {
552
553
  // Own posts the outbox names and the timeline index lacks come back here,
553
554
  // before anything acts on the index.
554
555
  await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
556
+ // Likes made before the liked list existed become its first entries.
557
+ try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
555
558
  this.lease.onLost = () => this.demote();
556
559
  this.lease.startRenewal();
557
560
  this.deliverer.startQueue();
@@ -573,24 +576,11 @@ export class Agent {
573
576
  this.tagfeed.start();
574
577
  this.startBsky();
575
578
  this.startAccts();
576
- // Scheduled posts: a 30s sweep publishes what has come due. The entry is
577
- // removed before publishing, so a slow publish cannot double-post; a
578
- // failed one is dropped with its reason in the log.
579
+ // Scheduled posts and polls whose time is up: a 30s sweep does what has
580
+ // come due (lib/core/scheduled.mjs).
579
581
  clearInterval(this.schedTimer);
580
582
  this.schedTimer = setInterval(() => {
581
- const due = this.store.getScheduled().filter(e => Date.parse(e.scheduledAt) <= Date.now());
582
- for (const e of due) {
583
- this.store.setScheduled(this.store.getScheduled().filter(x => x.id !== e.id));
584
- this.publisher.publishNote(e.params.status, {
585
- inReplyTo: e.params.inReplyTo, attachments: e.params.attachments,
586
- visibility: e.params.visibility, spoilerText: e.params.spoilerText,
587
- }).then(() => this.log(`scheduled post published (${e.id})`))
588
- .catch(err => this.log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
589
- }
590
- // A poll whose time is up is shut on the same sweep: it stops taking
591
- // answers here, and everyone holding it is told once.
592
- this.publisher.closeDuePolls()
593
- .catch(err => this.log(`closing polls: ${err.message}`));
583
+ publishDue(this.store, this.publisher, this.log).catch(err => this.log(`scheduled: ${err.message}`));
594
584
  }, 30_000);
595
585
  this.schedTimer.unref();
596
586
  // A CSV import interrupted by a restart or a handoff picks back up here.
@@ -267,14 +267,19 @@ fs.writeFileSync(path.join(site, '_redirects'), [
267
267
  // own /u/<handle>/ or a pod named outright. Its script is a module file of
268
268
  // its own, so it is served under the same `script-src 'self'` as the app.
269
269
  fs.mkdirSync(path.join(site, 'bb'), { recursive: true });
270
- for (const f of ['index.html', 'bb.js', 'read.mjs', 'masto.mjs', 'pod.mjs', 'markdown.mjs', 'seen.mjs', 'mine.mjs', 'private.mjs']) cp(`packages/fedipod-bb/site/${f}`, `bb/${f}`);
270
+ for (const f of ['index.html', 'bb.js', 'read.mjs', 'masto.mjs', 'pod.mjs', 'markdown.mjs', 'seen.mjs', 'mine.mjs', 'private.mjs', 'oidc-session.mjs']) cp(`packages/fedipod-bb/site/${f}`, `bb/${f}`);
271
271
  cp('web/admin/tokens.css', 'bb/tokens.css'); // the site's shared palette, size and family
272
- cp('lib/session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library
273
- cp('web/app/oidc-session.mjs', 'bb/oidc-session.mjs'); // and the browser build's binding of it, same database as the app
272
+ cp('lib/session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library the forum's binding names
274
273
  // The account library's demo page, with the library beside it, under /demo/.
275
274
  fs.mkdirSync(path.join(site, 'demo'), { recursive: true });
276
275
  for (const f of ['fedi-account.mjs', 'fedi-login.mjs', 'oidc-session.mjs']) cp(`lib/session/${f}`, `demo/${f}`);
277
276
  cp('web/admin/tokens.css', 'demo/tokens.css');
277
+ // The page a Mastodon app sends its person to (lib/gateway/masto-gateway.mjs),
278
+ // with the pod sign-in library and the palette beside it, as the demo has.
279
+ fs.mkdirSync(path.join(site, 'app-signin'), { recursive: true });
280
+ for (const f of ['index.html', 'app-signin.mjs']) cp(`web/app-signin/${f}`, `app-signin/${f}`);
281
+ for (const f of ['fedi-login.mjs', 'oidc-session.mjs']) cp(`lib/session/${f}`, `app-signin/${f}`);
282
+ cp('web/admin/tokens.css', 'app-signin/tokens.css');
278
283
  fs.writeFileSync(path.join(site, 'demo/index.html'),
279
284
  fs.readFileSync(path.join(root, 'lib/session/demo.html'), 'utf8').replace('../../web/admin/tokens.css', './tokens.css'));
280
285
  // Each file the forum page loads is named with a hash of its content. A
@@ -285,7 +290,7 @@ fs.writeFileSync(path.join(site, 'demo/index.html'),
285
290
  const stamp = (n) => createHash('sha256').update(fs.readFileSync(bb(n))).digest('hex').slice(0, 10);
286
291
  const sub = (n, pairs) => { let t = fs.readFileSync(bb(n), 'utf8');
287
292
  for (const [a, b] of pairs) t = t.split(a).join(b); fs.writeFileSync(bb(n), t); };
288
- sub('oidc-session.mjs', [["'../../lib/session/oidc-session.mjs'", `'./solid-oidc-session.mjs?v=${stamp('solid-oidc-session.mjs')}'`]]);
293
+ sub('oidc-session.mjs', [["'fediverse-session/oidc-session.mjs'", `'./solid-oidc-session.mjs?v=${stamp('solid-oidc-session.mjs')}'`]]);
289
294
  sub('pod.mjs', [["'./oidc-session.mjs'", `'./oidc-session.mjs?v=${stamp('oidc-session.mjs')}'`],
290
295
  ["'./markdown.mjs'", `'./markdown.mjs?v=${stamp('markdown.mjs')}'`],
291
296
  ["'./private.mjs'", `'./private.mjs?v=${stamp('private.mjs')}'`],
@@ -336,6 +341,14 @@ fs.writeFileSync(path.join(site, '_headers'), [
336
341
  ' X-Content-Type-Options: nosniff',
337
342
  ' Referrer-Policy: same-origin',
338
343
  '',
344
+ // The page a Mastodon app sends its person to signs them in at their pod,
345
+ // so it reaches the pod's identity provider.
346
+ '/app-signin/*',
347
+ ' Cache-Control: no-cache',
348
+ " Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
349
+ ' X-Content-Type-Options: nosniff',
350
+ ' Referrer-Policy: same-origin',
351
+ '',
339
352
  // The account demo reads pods and Mastodon servers and shows their avatars.
340
353
  '/demo/*',
341
354
  ' Cache-Control: no-cache',
@@ -136,6 +136,8 @@ $('new-actor-form').addEventListener('submit', async (ev) => {
136
136
  };
137
137
  if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
138
138
  else answers.pod = $('new-pod').value.trim();
139
+ answers.container = $('new-container').value.trim();
140
+ answers.createIndex = $('new-createIndex').checked;
139
141
  $('new-actor-go').disabled = true;
140
142
  say(`setting up ${handle || 'the new actor'} — this takes a while`);
141
143
  const r = await write('/new-actor', answers, `setting up ${handle}`);
@@ -28,11 +28,16 @@ async function refreshGateway() {
28
28
  $('gateway-close').hidden = !known || !!st.closed;
29
29
  if (known && st.closed) GATEWAY_WORD.textContent += ' — this address is closed';
30
30
  else if (known && st.paused) GATEWAY_WORD.textContent += st.pausedBy === 'owner' ? ' — paused by you' : ' — paused';
31
+ // Whether the gateway keeps the account running while FediPod is closed.
32
+ const kp = g.keeper;
33
+ $('gateway-keep-on').hidden = !kp || !known || !!st.closed || kp.on;
34
+ $('gateway-keep-off').hidden = !kp || !known || !!st.closed || !kp.on;
35
+ if (kp?.on && known && !st.closed) GATEWAY_WORD.textContent += ' — kept running while you are away';
31
36
  } else {
32
37
  GATEWAY_WORD.textContent = '';
33
38
  GW_OPEN_ATTACH.hidden = false;
34
39
  GW_OPEN_DETACH.hidden = true;
35
- for (const id of ['gateway-pause', 'gateway-resume', 'gateway-close']) $(id).hidden = true;
40
+ for (const id of ['gateway-pause', 'gateway-resume', 'gateway-keep-on', 'gateway-keep-off', 'gateway-close']) $(id).hidden = true;
36
41
  }
37
42
  }
38
43
  const setPausedAtGateway = async (paused) => {
@@ -43,6 +48,14 @@ const setPausedAtGateway = async (paused) => {
43
48
  };
44
49
  $('gateway-pause').addEventListener('click', () => setPausedAtGateway(true));
45
50
  $('gateway-resume').addEventListener('click', () => setPausedAtGateway(false));
51
+ const setKept = async (on) => {
52
+ const r = await write('/gateway/keep', { on },
53
+ on ? 'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed'
54
+ : 'stopped — your account acts only while FediPod is open');
55
+ if (r) refreshGateway();
56
+ };
57
+ $('gateway-keep-on').addEventListener('click', () => setKept(true));
58
+ $('gateway-keep-off').addEventListener('click', () => setKept(false));
46
59
  const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
47
60
  function gwPreviews() {
48
61
  $('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
@@ -272,6 +272,15 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
272
272
  <label for="new-pod">Pod address</label>
273
273
  <input type="url" id="new-pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
274
274
  </div>
275
+ <label for="new-container">Store your data in a container named fedipod, inside this container</label>
276
+ <input type="text" id="new-container" autocomplete="off" spellcheck="false" placeholder="/"
277
+ aria-describedby="new-container-hint">
278
+ <p class="hint" id="new-container-hint">Leave it empty for your pod's root. This is permanent.</p>
279
+ <p id="new-typeindex-text">FediPod records where the account lives in your pod's public type index,
280
+ the list Solid apps use to find your things. A new pod has none; setup stops if yours has none
281
+ and you have not said yes here. It will be readable by anyone, like your profile.</p>
282
+ <label class="choice"><input type="checkbox" id="new-createIndex" aria-describedby="new-typeindex-text">
283
+ If my pod has no public type index, create one</label>
275
284
  </fieldset>
276
285
 
277
286
  <p>
@@ -335,6 +344,10 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
335
344
  title=" Stop keeping the posts sent to you until you resume; follows still arrive">Pause my account</button>
336
345
  <button type="button" id="gateway-resume" class="inline" hidden
337
346
  title=" Keep the posts sent to you again">Resume my account</button>
347
+ <button type="button" id="gateway-keep-on" class="inline" hidden
348
+ title=" Let this gateway accept follows, try again what failed to go out and publish scheduled posts while FediPod is closed">Keep my account running while I'm away</button>
349
+ <button type="button" id="gateway-keep-off" class="inline" hidden
350
+ title=" Act only while FediPod is open; the rule naming this gateway comes off your pod">Stop keeping it running</button>
338
351
  <button type="button" id="gateway-close" class="inline danger" hidden data-confirm="close-address"
339
352
  title=" Close your address at this gateway for good; nothing on your pod is touched">Close this address</button>
340
353
  </span>
@@ -1,5 +1,5 @@
1
1
  // oauth-signin.mjs — a client asked this account to sign its owner in. The
2
- // owner signs in at their own pod, with the fediverse-account library, and the
2
+ // owner signs in at their own pod, with the fediverse-session library, and the
3
3
  // page proves that sign-in to the account with one signed request; the account
4
4
  // then hands the client its code. No password of ours anywhere.
5
5
  import { fediLogin } from './fedi-login.mjs';
@@ -269,6 +269,9 @@ function render() {
269
269
  ['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
270
270
  config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
271
271
  ['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
272
+ // Where on the pod the account's data lives: the container its owner chose.
273
+ ['stored at', config.remotePod && config.root ? config.remotePod + config.root : null,
274
+ config.remotePod && config.root ? { href: config.remotePod + config.root, blank: true } : null],
272
275
  ['Other identities', 'ctl'],
273
276
  ['local store', config.home],
274
277
  // The address you actually open, not the bare number — the named origin when
@@ -362,7 +365,7 @@ function render() {
362
365
  if (!config.address) {
363
366
  const p = document.createElement('p');
364
367
  p.className = 'warn';
365
- p.textContent = `${config.remotePod} is not the root of its own host, so this actor cannot be `
368
+ p.textContent = `${config.remotePod} is a suffixed pod, so this actor cannot be `
366
369
  + 'discovered as a handle by other servers. Posting and reading still work.';
367
370
  $('pane-identity').appendChild(p);
368
371
  }
@@ -151,7 +151,7 @@ button:disabled { opacity: .5; cursor: default; }
151
151
  <input type="url" id="issuerOther" name="issuerOther" autocomplete="url" placeholder="https://pods.example.org">
152
152
  </div>
153
153
  <p class="hint" id="issuer-new-hint">The Community Solid Server providers from
154
- solidproject.org. A provider that puts pods on paths gives your address at a
154
+ solidproject.org. A provider that makes suffixed pods gives your address at a
155
155
  gateway instead.</p>
156
156
  </div>
157
157
 
@@ -184,6 +184,22 @@ button:disabled { opacity: .5; cursor: default; }
184
184
  <label for="pod">Pod address</label>
185
185
  <input type="url" id="pod" name="pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
186
186
  </div>
187
+
188
+ <div id="row-container">
189
+ <label for="container">Store your data in a container named fedipod, inside this container</label>
190
+ <input type="text" id="container" name="container" autocomplete="off" spellcheck="false"
191
+ placeholder="/" aria-describedby="container-hint">
192
+ <p class="hint" id="container-hint">Leave it empty for your pod's root. This is permanent.
193
+ <span id="place"></span></p>
194
+ </div>
195
+
196
+ <div id="row-typeindex" hidden>
197
+ <p id="typeindex-text">Your pod has no public type index, the list Solid apps use to find your
198
+ things. FediPod needs one to record where your account lives. It will be readable by anyone,
199
+ like your profile.</p>
200
+ <label class="choice"><input type="checkbox" id="createIndex" name="createIndex"
201
+ aria-describedby="typeindex-text"> Create a public type index on my pod</label>
202
+ </div>
187
203
  </fieldset>
188
204
 
189
205
  <p id="row-reenter" class="warn" hidden>Finishing a setup that already has an account credential.
@@ -108,6 +108,9 @@ function answers() {
108
108
  };
109
109
  if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
110
110
  else a.pod = f.pod.value.trim();
111
+ // The container that holds fedipod/, and the yes to a new public type index.
112
+ a.container = f.container.value.trim();
113
+ a.createIndex = f.createIndex.checked;
111
114
  if (!state.resumable && !state.passwordSupplied) a.password = f.password.value;
112
115
  return a;
113
116
  }
@@ -116,13 +119,13 @@ function answers() {
116
119
  // account?", in the same words, as you type.
117
120
  const NOTES = {
118
121
  'pod-is-a-path': (a) =>
119
- `This pod is a path on ${host(a.pod)}, not the root of its own host. WebFinger is `
120
- + 'answered only at a host root, which this pod cannot write to, so other servers '
122
+ `This pod is suffixed (on ${host(a.pod)}), not subdomained. WebFinger is `
123
+ + 'answered only for a subdomained pod, so other servers '
121
124
  + 'will not find this address. Posting and reading still work; being discovered does not.',
122
125
  };
123
126
  const REFUSALS = {
124
127
  'group-needs-host-root': () =>
125
- 'A group needs a pod at the root of its own host, or nobody could ever find it. '
128
+ 'A group needs a subdomained pod, or nobody could ever find it. '
126
129
  + 'Give the group a pod of its own, or set this up as a person.',
127
130
  };
128
131
  const host = (u) => { try { return new URL(u).host; } catch { return u; } };
@@ -149,6 +152,11 @@ async function preview() {
149
152
  const { json } = await postJson('/setup/check', a);
150
153
  if (!json) return;
151
154
  $('preview').textContent = json.address || '…';
155
+ // Where the data will go, and the type index question when the pod has
156
+ // none (a new pod never does).
157
+ $('place').textContent = json.placeProblem ? json.placeProblem : (json.place ? `Your data will be at ${json.place}.` : '');
158
+ $('row-typeindex').hidden = json.typeIndex === true;
159
+ try { $('container').placeholder = a.mode === 'existing' && a.pod ? new URL(a.pod).pathname : '/'; } catch { /* not a URL yet */ }
152
160
  // The address-shape choice: hidden for a group (a group cannot front yet),
153
161
  // locked to the gateway for a pod on a suffix-based host, an open choice
154
162
  // for a pod at its own host.
@@ -165,7 +173,7 @@ async function preview() {
165
173
  if (note) {
166
174
  note.hidden = !forced;
167
175
  note.textContent = forced
168
- ? 'Your pod is on a suffix-based host, so its address lives at the gateway. Your posts, key and data stay on your pod.'
176
+ ? 'Your pod is suffixed, so its address lives at the gateway. Your posts, key and data stay on your pod.'
169
177
  : '';
170
178
  }
171
179
  }
@@ -188,6 +196,11 @@ async function preview() {
188
196
  async function onSubmit(ev) {
189
197
  ev.preventDefault();
190
198
  $('form-error').textContent = '';
199
+ // No type index and no yes: stop here, before anything is sent.
200
+ if (!$('row-typeindex').hidden && !$('createIndex').checked) {
201
+ $('form-error').textContent = 'Setup stopped. Nothing was written to your pod. FediPod needs a public type index to record where your account lives.';
202
+ return;
203
+ }
191
204
  $('submit').disabled = true;
192
205
  const { status, json } = await postJson('/setup', answers());
193
206
  if (status !== 202) {
@@ -300,7 +313,7 @@ function paneDone(result) {
300
313
  $('done-address').textContent = result?.address || `@${result?.handle} — no resolvable address`;
301
314
  const notes = [];
302
315
  if (!result?.resolvable) {
303
- notes.push(`${result?.pod} is not the root of its own host, so this address cannot be `
316
+ notes.push(`${result?.pod} is a suffixed pod, so this address cannot be `
304
317
  + 'discovered by other servers. Posting and reading still work.');
305
318
  }
306
319
  if (result?.unreachable?.length) {
package/web/app/README.md CHANGED
@@ -75,7 +75,7 @@ See the `MastoApi` options in `agent.mjs`.
75
75
  |---|---|
76
76
  | **Streaming** | a service worker answers fetches, not sockets. No streaming URL is advertised, so clients poll. |
77
77
  | **Web push** | `shims/web-push.mjs` is a no-op. `vapid` is omitted, and a client that subscribes anyway gets a 422 rather than a subscription nothing will push to. |
78
- | **Scheduled posts** | nothing runs between now and the scheduled time. A `scheduled_at` is refused with a 422 that says so — accepting one was silent loss. |
78
+ | **Scheduled posts** | only while the gateway keeps the account running (`setKeeper`), because then something is running when the time comes: the app publishes what falls due while it is open, the gateway's keeper while it is closed. Otherwise a `scheduled_at` is refused with a 422 that says so — accepting one was silent loss. |
79
79
  | **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the DeviceAgent. See `groups.md`. |
80
- | **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a pod on a suffix-based host is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
80
+ | **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a suffixed pod is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
81
81
  | **Moving the private half** | `/state-move` is about filesystem paths and `credential.json`. A browser has neither; its private half is always on the pod. |