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.
- package/README.md +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- 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.
|
package/lib/server/embed.mjs
CHANGED
|
@@ -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
|
|
51
|
-
const url = apUrls(base,
|
|
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
|
|
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;
|
package/lib/session/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# fediverse-
|
|
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-
|
|
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-
|
|
60
|
+
npm install fediverse-session
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
```js
|
|
64
|
-
import { fediAccount } from 'fediverse-
|
|
64
|
+
import { fediAccount } from 'fediverse-session';
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
## Use
|
|
68
68
|
|
|
69
69
|
```js
|
|
70
|
-
import { fediAccount } from 'fediverse-
|
|
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
|
package/lib/session/demo.html
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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 (
|
|
82
|
-
seen.add(
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
85
|
-
quads.push(...(await jsonld.toRDF(
|
|
86
|
-
collected += (
|
|
87
|
-
|
|
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 (
|
|
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
|
},
|
package/lib/session/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "fediverse-
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
577
|
-
//
|
|
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
|
-
|
|
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.
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -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', [["'
|
|
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',
|
package/web/admin/actors.js
CHANGED
|
@@ -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}`);
|
package/web/admin/gateway.js
CHANGED
|
@@ -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`;
|
package/web/admin/index.html
CHANGED
|
@@ -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-
|
|
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';
|
package/web/admin/record.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/web/admin/setup/setup.js
CHANGED
|
@@ -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
|
|
120
|
-
+ 'answered only
|
|
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
|
|
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
|
|
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
|
|
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** |
|
|
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
|
|
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. |
|