fedipod 1.40.0 → 1.42.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 +5 -0
- package/architecture.md +1 -3
- package/architecture.svg +93 -85
- package/gateway.md +7 -2
- package/lib/client/c2s.mjs +16 -5
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/connections/vault.mjs +1 -1
- package/lib/core/agent.mjs +720 -0
- package/lib/core/gateway-move.mjs +58 -0
- package/lib/core/keys.mjs +1 -1
- package/lib/core/lease.mjs +4 -3
- package/lib/core/publisher/index.mjs +4 -6
- package/lib/core/publisher/notes.mjs +73 -4
- package/lib/core/remote.mjs +54 -0
- package/lib/core/shapes/shapes-text.mjs +11 -3
- package/lib/core/wire.mjs +4 -1
- package/lib/device/admin/index.mjs +4 -11
- package/lib/device/admin/routes/setup.mjs +60 -5
- package/lib/device/admin/server.mjs +18 -5
- package/lib/device/cli/commands/account.mjs +1 -1
- package/lib/device/cli/commands/run.mjs +2 -2
- package/lib/device/cli/commands/service.mjs +1 -1
- package/lib/device/cli/commands/setup.mjs +1 -1
- package/lib/device/cli/commands/state.mjs +1 -1
- package/lib/device/cli/context.mjs +1 -1
- package/lib/{gateway → device}/directory.mjs +1 -1
- package/lib/device/gateway-move.mjs +3 -58
- package/lib/device/home.mjs +3 -50
- package/lib/{client → device}/localapi.mjs +2 -2
- package/lib/device/remote.mjs +3 -54
- package/lib/gateway/copy.mjs +7 -1
- package/lib/gateway/front-core.mjs +6 -16
- package/lib/gateway/front-pages.mjs +31 -0
- package/lib/gateway/keeper-session.mjs +10 -1
- package/lib/gateway/masto-gateway.mjs +61 -12
- package/lib/gateway/outbox-door.mjs +31 -0
- package/lib/gateway/state-api.mjs +39 -7
- package/lib/server/embed.mjs +4 -4
- package/lib/shared/files.mjs +49 -0
- package/lib/shared/ua.mjs +3 -0
- package/lib/surface/index.mjs +7 -0
- package/lib/{device/admin → surface}/origins.mjs +1 -1
- package/lib/{device/admin → surface}/routes/gateway.mjs +7 -11
- package/lib/{device/admin → surface}/routes/lifecycle.mjs +3 -3
- package/lib/{device/admin → surface}/routes/owner.mjs +4 -60
- package/lib/{device/admin → surface}/routes/social.mjs +1 -1
- package/lib/{device/admin → surface}/static.mjs +6 -1
- package/lib/{device/admin → surface}/surface.mjs +19 -16
- package/package.json +23 -5
- package/run-agent.mjs +18 -714
- package/scripts/build-app.mjs +4 -0
- package/scripts/check-netlify-bundles.mjs +84 -0
- package/scripts/check-pod-calls.mjs +42 -0
- package/scripts/vendor-sanitize.mjs +27 -0
- package/vendor/idp-grant.cjs +11 -5
- package/vendor/sanitize-html.cjs +11456 -0
- package/web/app/agent.mjs +4 -2
- package/web/app/copy-mode.mjs +27 -3
- package/web/app/dist/boot.js.map +1 -1
- package/web/app/dist/sw.js +110 -24
- package/web/app/dist/sw.js.map +4 -4
- package/web/app-signin/app-signin.mjs +2 -2
- package/web/app-signin/index.html +13 -5
- package/browser.svg +0 -72
- /package/lib/{device/admin → surface}/routes/connections.mjs +0 -0
package/lib/device/home.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
-
import {
|
|
16
|
+
import { writeJsonAtomic } from '../shared/files.mjs';
|
|
17
|
+
|
|
18
|
+
export { writeFileAtomic, writeJsonAtomic, tildify } from '../shared/files.mjs';
|
|
17
19
|
|
|
18
20
|
export const CURRENT_ROOT = '.fedipod';
|
|
19
21
|
export const LEGACY_ROOTS = ['.solid-activitypub', '.activitypod']; // most recent first
|
|
@@ -31,39 +33,6 @@ export function apRoot(homedir = os.homedir()) {
|
|
|
31
33
|
|
|
32
34
|
export const isLegacyRoot = (root) => LEGACY_ROOTS.includes(path.basename(root));
|
|
33
35
|
|
|
34
|
-
// Write one of this home's files without a window in which it is half there.
|
|
35
|
-
//
|
|
36
|
-
// lib/storage.mjs goes to the trouble of tmp+rename for state documents that
|
|
37
|
-
// its own header calls rebuildable from the pod. The files here are the ones
|
|
38
|
-
// that are NOT: keys.json is the private key remote servers have cached, and
|
|
39
|
-
// credential.json holds a secret a Solid server mints once and will not mint
|
|
40
|
-
// again. A plain writeFileSync truncates first, so a crash, a power loss or a
|
|
41
|
-
// full disk between truncate and write leaves an empty or half-written file
|
|
42
|
-
// where the identity used to be.
|
|
43
|
-
//
|
|
44
|
-
// rename(2) is atomic on POSIX, so a reader — or a backup running at the wrong
|
|
45
|
-
// moment — sees either the old file or the new one. fsync before it, so the
|
|
46
|
-
// content is on the platter and not merely in the page cache when the rename
|
|
47
|
-
// lands. Windows has no atomic rename over an existing file; there the copy
|
|
48
|
-
// path is still better than a truncate, and this is a best effort by design.
|
|
49
|
-
export function writeFileAtomic(file, body, { mode = 0o600 } = {}) {
|
|
50
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
51
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
52
|
-
const fd = fs.openSync(tmp, 'w', mode);
|
|
53
|
-
try {
|
|
54
|
-
fs.writeFileSync(fd, body);
|
|
55
|
-
fs.fsyncSync(fd);
|
|
56
|
-
} finally {
|
|
57
|
-
fs.closeSync(fd);
|
|
58
|
-
}
|
|
59
|
-
fs.renameSync(tmp, file);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// The same, for the JSON these files all happen to be.
|
|
63
|
-
export function writeJsonAtomic(file, obj, opts) {
|
|
64
|
-
writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n', opts);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
36
|
export const profilesDir = (root) => path.join(root, 'profiles');
|
|
68
37
|
|
|
69
38
|
// The root an arbitrary home belongs to. `<root>/profiles/<name>` is a profile;
|
|
@@ -136,22 +105,6 @@ export const profileHome = (root, name) => path.join(profilesDir(root), name);
|
|
|
136
105
|
export const rootHoldsIdentity = (root) => fs.existsSync(path.join(root, 'credential.json'));
|
|
137
106
|
|
|
138
107
|
|
|
139
|
-
// For DISPLAY only: `~/.fedipod` reads at a glance where
|
|
140
|
-
// `/home/jeff/.fedipod` has to be parsed, and the home directory is
|
|
141
|
-
// the least interesting part of every path this prints. A `file:` URL is a path
|
|
142
|
-
// wearing a scheme, so it is shown as the path — which is also what you would
|
|
143
|
-
// type, since `state --to` takes either.
|
|
144
|
-
//
|
|
145
|
-
// Never used to build a path. `~` is the shell's, not the filesystem's, and
|
|
146
|
-
// anything that resolves one must expand it first.
|
|
147
|
-
export function tildify(p, homedir = os.homedir()) {
|
|
148
|
-
if (!p) return p;
|
|
149
|
-
let s = String(p);
|
|
150
|
-
if (s.startsWith('file://')) { try { s = fileURLToPath(s); } catch { return s; } }
|
|
151
|
-
if (s === homedir) return '~';
|
|
152
|
-
return s.startsWith(homedir + path.sep) ? '~' + s.slice(homedir.length) : s;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
108
|
// Using an identity is what makes it the default. Called when an agent starts
|
|
156
109
|
// for one — not on every read, or `--profile other status` would quietly move
|
|
157
110
|
// the machine's idea of "you" while only asking a question.
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
import https from 'node:https';
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { rootOf, apRoot } from '
|
|
16
|
-
import { certPaths } from '
|
|
15
|
+
import { rootOf, apRoot } from './home.mjs';
|
|
16
|
+
import { certPaths } from './certs.mjs';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The authorities worth offering for a loopback call: this install's, and the
|
package/lib/device/remote.mjs
CHANGED
|
@@ -1,54 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
// Everything about talking to a pod — the verbs, the ACL documents, the
|
|
6
|
-
// deletion deny-list, the Retry-After cooldown, the container listing — lives
|
|
7
|
-
// in lib/pod/transport.mjs, which knows nothing about Node and is shared with
|
|
8
|
-
// the browser. What is left here is the one thing that genuinely differs: how
|
|
9
|
-
// the session is obtained. That top-level `require` of the vendor grant is also
|
|
10
|
-
// exactly why the split exists — it cannot be bundled for a service worker.
|
|
11
|
-
|
|
12
|
-
import { createRequire } from 'node:module';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { PodTransport } from '../pod/transport.mjs';
|
|
16
|
-
|
|
17
|
-
const require = createRequire(import.meta.url);
|
|
18
|
-
const vendorDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../vendor');
|
|
19
|
-
const { mintCredential, discoverTokenEndpoint, createGrantSession, revokeCredentialViaAccount } =
|
|
20
|
-
require(path.join(vendorDir, 'idp-grant.cjs'));
|
|
21
|
-
|
|
22
|
-
export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
|
|
23
|
-
|
|
24
|
-
// Re-exported so the callers that guard their own deletions — run-agent, the
|
|
25
|
-
// server package's live test, the smoke suite — keep importing it from here.
|
|
26
|
-
export { protectedFromDeletion } from '../pod/transport.mjs';
|
|
27
|
-
|
|
28
|
-
export class RemotePod extends PodTransport {
|
|
29
|
-
constructor(credential, { log = () => {}, home = null, session = null, role = 'agent' } = {}) {
|
|
30
|
-
// An injected session replaces the credential-backed one, and everything
|
|
31
|
-
// above it — the cooldown, the deny-list, the url map — is unchanged. That
|
|
32
|
-
// is how the agent runs inside a pod server: same class, no token to mint.
|
|
33
|
-
super(session || createGrantSession(credential, home ? {
|
|
34
|
-
backoffFile: path.join(home, 'backoff.json'),
|
|
35
|
-
tokenFile: path.join(home, 'token.json'),
|
|
36
|
-
} : {}), {
|
|
37
|
-
webId: credential.webId,
|
|
38
|
-
log,
|
|
39
|
-
role,
|
|
40
|
-
runtime: 'node',
|
|
41
|
-
// Fail fast for the window rather than sleeping it out: a daemon has
|
|
42
|
-
// timers behind it, and a request that waits is a timer that does not
|
|
43
|
-
// fire. The callers' existing retry paths take it from there.
|
|
44
|
-
cooldownMode: 'refuse',
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async warmup() { return this.session.warmup(); }
|
|
49
|
-
|
|
50
|
-
/** The grant's own counters, under the transport's. */
|
|
51
|
-
stats() {
|
|
52
|
-
return { ...(this.session.stats?.() || {}), ...super.stats() };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
1
|
+
// The pod transport lives in lib/core/remote.mjs; this path is kept for the
|
|
2
|
+
// callers that import it from here.
|
|
3
|
+
export { RemotePod, mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount, protectedFromDeletion } from '../core/remote.mjs';
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -263,7 +263,13 @@ export async function lockCopy(kv, h, { ms = 60_000, waitMs = 8_000, by = crypto
|
|
|
263
263
|
export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, log = () => {} }) {
|
|
264
264
|
if (await copyMeta(kv, h)) return { ok: true, already: true };
|
|
265
265
|
const podLease = new Lease({ url: stateUrl + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
|
|
266
|
-
if (!await podLease.acquire())
|
|
266
|
+
if (!await podLease.acquire()) {
|
|
267
|
+
// Held is another device acting on the pod; unreadable is the gateway's
|
|
268
|
+
// own identity refused or unanswered there, which no device has to do with.
|
|
269
|
+
return podLease.denied === 'unreadable'
|
|
270
|
+
? { ok: false, why: `the pod could not be read (${podLease.unreadable})` }
|
|
271
|
+
: { ok: false, why: 'the account is active on another device' };
|
|
272
|
+
}
|
|
267
273
|
const undo = async (why) => {
|
|
268
274
|
for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
|
|
269
275
|
await podLease.release().catch(() => {});
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// HTTPS box is another. UN-DEPLOYED — nothing in FediPod runs it.
|
|
17
17
|
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
|
-
import { handleDelivery
|
|
19
|
+
import { handleDelivery } from './gateway-core.mjs';
|
|
20
|
+
import { ownerPostAtDoor } from './outbox-door.mjs';
|
|
20
21
|
import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
|
|
21
22
|
import * as podRoot from '../pod/root.mjs';
|
|
22
23
|
import * as podPolicy from '../pod/policy.mjs';
|
|
@@ -529,6 +530,9 @@ async function route(request, ctx) {
|
|
|
529
530
|
handle, address, podHome, webId: webid, actorUrl,
|
|
530
531
|
kind: body.kind === 'group' ? 'group' : body.kind === 'application' ? 'application' : 'person',
|
|
531
532
|
gatewayWebId: ctx.gatewayWebId || null, hmacSecret,
|
|
533
|
+
// When the account was set up here: an app signing in a moment later is
|
|
534
|
+
// told it is still being got ready, not that it cannot be used.
|
|
535
|
+
attachedAt: new Date().toISOString(),
|
|
532
536
|
...(inboxUrl ? { inboxUrl } : {}),
|
|
533
537
|
...(fronted ? {} : { inboxOnly: true }),
|
|
534
538
|
};
|
|
@@ -847,21 +851,7 @@ async function route(request, ctx) {
|
|
|
847
851
|
return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
848
852
|
}
|
|
849
853
|
if (request.method === 'POST') {
|
|
850
|
-
|
|
851
|
-
headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
|
|
852
|
-
body: JSON.stringify(obj) });
|
|
853
|
-
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
854
|
-
if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
|
|
855
|
-
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
856
|
-
if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
857
|
-
const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
|
|
858
|
-
const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
|
|
859
|
-
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid,
|
|
860
|
-
exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
|
|
861
|
-
console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
|
|
862
|
-
if (status !== 201) return json(status, { error: reason });
|
|
863
|
-
return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
|
|
864
|
-
note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
|
|
854
|
+
return ownerPostAtDoor(request, pathname, ctx, rec, up.handle, { cors, identFor, verifyPodToken, webidUnderPod });
|
|
865
855
|
}
|
|
866
856
|
// Its owner, signed in, reads every message the account produced (§5.1:
|
|
867
857
|
// the outbox is filtered by who asks); that view is on the pod, under the
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// front-pages.mjs — the front's pages and the files they load, read from
|
|
2
|
+
// beside this package: the opt-in page, the roster, the notices page, the
|
|
3
|
+
// new-account page, each page's own script, the sign-in library and the
|
|
4
|
+
// installer. A file that is not there comes back null, and the route it
|
|
5
|
+
// feeds answers 404.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const FRONT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../web/front');
|
|
12
|
+
const SCRIPTS = ['new-account.js', 'run.js', 'admin.js', 'notices.js'];
|
|
13
|
+
|
|
14
|
+
const readOrNull = (name) => {
|
|
15
|
+
try { return fs.readFileSync(path.join(FRONT_DIR, name), 'utf8'); } catch { return null; }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The pages routeFront serves, in the shape its options take them. */
|
|
19
|
+
export function frontPages() {
|
|
20
|
+
const pageScripts = {};
|
|
21
|
+
for (const name of SCRIPTS) pageScripts[name] = readOrNull(name);
|
|
22
|
+
return {
|
|
23
|
+
signupPage: readOrNull('new-account.html'),
|
|
24
|
+
runPage: readOrNull('run.html'),
|
|
25
|
+
adminPage: readOrNull('admin.html'),
|
|
26
|
+
noticesPage: readOrNull('notices.html'),
|
|
27
|
+
authBundle: readOrNull('solid-oidc-client.js'),
|
|
28
|
+
pageScripts,
|
|
29
|
+
installScript: readOrNull('install.sh'),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
// keeper-session.mjs — the gateway's own pod identity at work: one grant per
|
|
2
2
|
// running copy, shared by every account it works for, so a round is one token,
|
|
3
3
|
// not one per account (keeper.mjs, state-api.mjs).
|
|
4
|
+
import crypto from 'node:crypto';
|
|
4
5
|
import grant from '../../vendor/idp-grant.cjs';
|
|
5
6
|
|
|
7
|
+
// The same credential is the same session, however it arrives: the deployment
|
|
8
|
+
// reads its settings into a new object each time, and comparing objects made
|
|
9
|
+
// a new session, and so a new sign-in at the issuer, for every request that
|
|
10
|
+
// reached a pod. Kept as a hash, so the secret is not held here a second time.
|
|
11
|
+
const markOf = (cred) => crypto.createHash('sha256')
|
|
12
|
+
.update(`${cred.clientId}\n${cred.secret}\n${cred.tokenEndpoint}`).digest('hex');
|
|
13
|
+
|
|
6
14
|
let shared = null;
|
|
7
15
|
export function keeperSession(cred) {
|
|
8
|
-
|
|
16
|
+
const mark = markOf(cred);
|
|
17
|
+
if (!shared || shared.mark !== mark) shared = { mark, session: grant.createGrantSession(cred) };
|
|
9
18
|
return shared.session;
|
|
10
19
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// (/app-signin/, web/app-signin/), which
|
|
8
8
|
// signs them in at their own pod
|
|
9
9
|
// GET /api/authorize?client_id=… what the page shows: the app's name
|
|
10
|
-
// GET /api/authorize?address=… the pod an address here belongs to
|
|
10
|
+
// GET /api/authorize?address=… the pod an address here, or a WebID, belongs to
|
|
11
11
|
// POST /api/authorize the pod sign-in, proved: a code for the app
|
|
12
12
|
// POST /oauth/token, /oauth/revoke the code for a token, and back
|
|
13
13
|
// GET /api/v1/instance, /api/v2/instance fedipod.net itself
|
|
@@ -97,6 +97,56 @@ function handleOf(address, host) {
|
|
|
97
97
|
return !at || at === String(host || '').toLowerCase() ? name : a;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The account an address names: a Fediverse address here ("you", "@you",
|
|
102
|
+
* "you@host", "@you@host", or a whole address on the person's own pod), or a
|
|
103
|
+
* WebID, which names the browser account here that belongs to it. An account
|
|
104
|
+
* FediPod has not finished setting up counts: the caller says it is not ready.
|
|
105
|
+
* Returns { handle, rec }, or { error: [status, message] }.
|
|
106
|
+
*/
|
|
107
|
+
async function accountFor(ctx, address, host) {
|
|
108
|
+
const a = String(address || '').trim();
|
|
109
|
+
const here = (r) => !!r && !r.movedTo && !r.closedAt;
|
|
110
|
+
if (/^https?:\/\//iu.test(a)) {
|
|
111
|
+
const mine = Object.entries(await ctx.listDirectory?.() || {}).filter(([, r]) => r?.webId === a && here(r));
|
|
112
|
+
// Kept running first: that is the one an app can use; then opened ones.
|
|
113
|
+
const kept = mine.filter(([, r]) => r.openedAt && r.keeper);
|
|
114
|
+
const opened = mine.filter(([, r]) => r.openedAt);
|
|
115
|
+
const pick = kept.length ? kept : opened.length ? opened : mine;
|
|
116
|
+
if (pick.length > 1) return { error: [409, 'this WebID has more than one account here; give the Fediverse address of the one to use'] };
|
|
117
|
+
if (!pick.length) return { error: [404, 'there is no account here for that WebID'] };
|
|
118
|
+
return { handle: pick[0][0], rec: pick[0][1] };
|
|
119
|
+
}
|
|
120
|
+
const handle = handleOf(a, host);
|
|
121
|
+
// Read fresh: a person is signing in, perhaps a moment after signing up.
|
|
122
|
+
const rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
|
|
123
|
+
if (!here(rec)) return { error: [404, 'there is no account with that address here'] };
|
|
124
|
+
return { handle, rec };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// An account here that an app cannot use. The forum and its categories are
|
|
128
|
+
// run by a server. A personal account set up in the last few minutes and not
|
|
129
|
+
// yet opened: FediPod has not finished its first start. An older one never
|
|
130
|
+
// opened in a browser: made before FediPod noted openings, or run by a server;
|
|
131
|
+
// the gateway cannot tell which. Opened but not kept: new, or its owner
|
|
132
|
+
// stopped keeping it running. The minute rests on a first start seen on
|
|
133
|
+
// 2026-09-26 (24 seconds from opening to kept).
|
|
134
|
+
const NEW_FOR_MS = 10 * 60_000;
|
|
135
|
+
const SERVER_RUN = 'This account is run by a server, not by FediPod in a browser, so apps can\'t sign in to it here.';
|
|
136
|
+
const NOT_READY_NEW = 'This account was set up moments ago, and FediPod is still getting it ready for apps. '
|
|
137
|
+
+ 'That usually takes under a minute. Leave FediPod open in your browser, then try again.';
|
|
138
|
+
const NOT_IN_BROWSER = 'An app can only sign in to an account that FediPod runs in your browser. '
|
|
139
|
+
+ 'If this is one, open it in FediPod first, then try again.';
|
|
140
|
+
const NOT_READY = 'This account can\'t be used from an app yet. If you set it up in the last few minutes, FediPod is '
|
|
141
|
+
+ 'still getting it ready: that usually takes under a minute, so leave FediPod open in your browser and try again. '
|
|
142
|
+
+ 'Otherwise, press "Keep my account running while I\'m away" on your account\'s manage page, then try again.';
|
|
143
|
+
function notReady(rec) {
|
|
144
|
+
if (rec.kind === 'group' || rec.kind === 'application') return SERVER_RUN;
|
|
145
|
+
if (rec.openedAt) return NOT_READY;
|
|
146
|
+
const setUp = Date.parse(rec.attachedAt || '');
|
|
147
|
+
return setUp && Date.now() - setUp < NEW_FOR_MS ? NOT_READY_NEW : NOT_IN_BROWSER;
|
|
148
|
+
}
|
|
149
|
+
|
|
100
150
|
// An account an app may sign in to: a browser account here, kept running, not
|
|
101
151
|
// moved or closed.
|
|
102
152
|
const usable = (ctx, rec) => !!(rec && rec.openedAt && keptNow(ctx, rec) && !rec.movedTo && !rec.closedAt);
|
|
@@ -409,14 +459,11 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
409
459
|
let where = null; try { where = redirect.startsWith('urn:') ? null : new URL(redirect).host || null; } catch { /* a custom scheme */ }
|
|
410
460
|
return json(200, { name: app.name, website: app.website || null, sendsTo: where });
|
|
411
461
|
}
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
462
|
+
const found = await accountFor(ctx, url.searchParams.get('address'), host);
|
|
463
|
+
if (found.error) return json(found.error[0], { error: found.error[1] });
|
|
464
|
+
const { handle, rec } = found;
|
|
415
465
|
if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
416
|
-
if (!rec.keeper) {
|
|
417
|
-
return json(409, { error: 'this account works only while FediPod is open. To use it from an app, turn on '
|
|
418
|
-
+ '"Keep my account running while I\'m away" on its manage page first.' });
|
|
419
|
-
}
|
|
466
|
+
if (!rec.openedAt || !rec.keeper) return json(409, { error: notReady(rec) });
|
|
420
467
|
return json(200, { handle, webId: rec.webId });
|
|
421
468
|
}
|
|
422
469
|
|
|
@@ -425,10 +472,11 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
425
472
|
const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
|
|
426
473
|
if (!webid) return json(401, { error: 'sign in at your pod first' });
|
|
427
474
|
const body = await request.clone().json().catch(() => ({}));
|
|
428
|
-
const
|
|
429
|
-
|
|
475
|
+
const found = await accountFor(ctx, body.address, host);
|
|
476
|
+
if (found.error) return json(found.error[0], { error: found.error[1] });
|
|
477
|
+
const { handle, rec } = found;
|
|
430
478
|
if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
431
|
-
if (!usable(ctx, rec)) return json(
|
|
479
|
+
if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
|
|
432
480
|
if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
|
|
433
481
|
const app = await appOf(kv, body.client_id);
|
|
434
482
|
if (!app) return json(400, { error: 'this app has not registered here' });
|
|
@@ -436,7 +484,8 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
436
484
|
if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
|
|
437
485
|
return json(400, { error: 'this app asked to be sent somewhere it did not register' });
|
|
438
486
|
}
|
|
439
|
-
|
|
487
|
+
// The owner, proved: not an app checking in, so not made to wait.
|
|
488
|
+
const made = await ensureCopy(ctx, handle, rec, log, { owner: true });
|
|
440
489
|
if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
441
490
|
const code = rand(24);
|
|
442
491
|
await putJson(kv, `code/${sha(code)}`, {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// outbox-door.mjs — the owner's own post, taken at the outbox door
|
|
2
|
+
// (front-core.mjs routes it here). The owner is proved by a pod sign-in. The
|
|
3
|
+
// post is written into the pod inbox with a receipt (gateway-core:
|
|
4
|
+
// handleOwnerPost); for a kept account whose FediPod is closed, the keeper is
|
|
5
|
+
// started at once to publish it.
|
|
6
|
+
import { handleOwnerPost } from './gateway-core.mjs';
|
|
7
|
+
import { keptNow } from './copy.mjs';
|
|
8
|
+
import { isPresent } from './held-mail.mjs';
|
|
9
|
+
|
|
10
|
+
export async function ownerPostAtDoor(request, pathname, ctx, rec, handle, { cors, identFor, verifyPodToken, webidUnderPod }) {
|
|
11
|
+
const json = (status, obj, extra = {}) => ({ status,
|
|
12
|
+
headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
|
|
13
|
+
body: JSON.stringify(obj) });
|
|
14
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
15
|
+
if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
|
|
16
|
+
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
17
|
+
if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
18
|
+
const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
|
|
19
|
+
const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
|
|
20
|
+
{ podPut: (u, b, ct) => ctx.podPut(handle, u, b, ct), ownerWebId: webid,
|
|
21
|
+
exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
|
|
22
|
+
console.log(`door @${handle}: owner post → ${status} (${reason})`);
|
|
23
|
+
if (status !== 201) return json(status, { error: reason });
|
|
24
|
+
// Kept and closed: the keeper publishes it now rather than at the next visit.
|
|
25
|
+
let now = false;
|
|
26
|
+
if (keptNow(ctx, rec) && ctx.startKeeper && !(await isPresent(ctx, handle).catch(() => false))) {
|
|
27
|
+
now = await ctx.startKeeper(handle).then(() => true, (e) => { console.log(`door @${handle}: keeper not started: ${e?.message || e}`); return false; });
|
|
28
|
+
}
|
|
29
|
+
return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
|
|
30
|
+
note: now ? 'it goes out in a moment' : 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
|
|
31
|
+
}
|
|
@@ -49,19 +49,44 @@ export const stateUrlOf = (rec) => `${rec.podHome.replace(/\/?$/u, '/')}ap-state
|
|
|
49
49
|
export const keepsCopies = (ctx) => !!(ctx.copyKv && ctx.keeperWebId && ctx.keeperFetch && ctx.stateSecret);
|
|
50
50
|
|
|
51
51
|
const FILL_RETRY_MS = 5 * 60_000;
|
|
52
|
+
// The gateway's own sign-in at its pod's issuer refused: nothing an account
|
|
53
|
+
// does changes that, so no account's copy is tried for this long. Held against
|
|
54
|
+
// the credential it failed with (ctx.keeperMark); a new one is tried at once.
|
|
55
|
+
const LOGIN_PAUSE_MS = 15 * 60_000;
|
|
56
|
+
const LOGIN_REFUSED = '_keeper/login-refused';
|
|
57
|
+
const refusedSignIn = (why) => /token request failed \(HTTP 40[01]\)/u.test(why || '');
|
|
58
|
+
async function loginRefused(ctx) {
|
|
59
|
+
let r = null;
|
|
60
|
+
try { r = JSON.parse((await ctx.copyKv.get(LOGIN_REFUSED))?.text || 'null'); } catch { /* none */ }
|
|
61
|
+
return r && r.mark === (ctx.keeperMark || '') && Date.now() - r.at < LOGIN_PAUSE_MS ? r : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The last failed attempt: { at, why }. Written as a bare time before 2026-09-26.
|
|
65
|
+
async function lastFailure(kv, handle) {
|
|
66
|
+
const text = (await kv.get(`${handle}/fill-failed-at`))?.text || '';
|
|
67
|
+
try { const f = JSON.parse(text); return typeof f === 'number' ? { at: f, why: null } : { at: Number(f?.at) || 0, why: f?.why || null }; }
|
|
68
|
+
catch { return { at: 0, why: null }; }
|
|
69
|
+
}
|
|
70
|
+
const noteFailure = (kv, handle, why = null) => kv.set(`${handle}/fill-failed-at`, JSON.stringify({ at: Date.now(), why })).catch(() => {});
|
|
52
71
|
|
|
53
72
|
/**
|
|
54
73
|
* The copy for an account, made from the pod when there is none: one maker at
|
|
55
74
|
* a time, and after a failed attempt not again for five minutes, so an app
|
|
56
75
|
* checking in every minute does not read the pod every minute. `owner`: the
|
|
57
|
-
* owner
|
|
58
|
-
*
|
|
76
|
+
* owner asking, from their own browser or by signing in at the app sign-in
|
|
77
|
+
* page, which is not made to wait. After the gateway's own sign-in is refused,
|
|
78
|
+
* no account is tried for fifteen minutes, the owner's included, unless the
|
|
79
|
+
* credential has changed since. Returns { ok } or { ok: false, status, why }.
|
|
59
80
|
*/
|
|
60
81
|
export async function ensureCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
|
|
61
82
|
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
// The owner too: it is the gateway's credential that has to change.
|
|
84
|
+
const refused = await loginRefused(ctx);
|
|
85
|
+
if (refused) return { ok: false, status: 503, why: `this account could not be opened: ${refused.why}. Try again later` };
|
|
86
|
+
const failed = await lastFailure(ctx.copyKv, handle);
|
|
87
|
+
if (!owner && Date.now() - failed.at < FILL_RETRY_MS) {
|
|
88
|
+
return { ok: false, status: 503,
|
|
89
|
+
why: `this account could not be opened a moment ago${failed.why ? `: ${failed.why}` : ''}. Try again in a few minutes` };
|
|
65
90
|
}
|
|
66
91
|
const podFetch = await ctx.keeperFetch();
|
|
67
92
|
if (!podFetch) return { ok: false, status: 501, why: 'this gateway cannot reach pods for accounts' };
|
|
@@ -71,11 +96,16 @@ export async function ensureCopy(ctx, handle, rec, log = console.log, { owner =
|
|
|
71
96
|
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
72
97
|
// Marked failed before it starts and cleared when it is done, so an attempt
|
|
73
98
|
// cut off part way (a request's time running out) counts as failed too.
|
|
74
|
-
await ctx.copyKv
|
|
99
|
+
await noteFailure(ctx.copyKv, handle);
|
|
75
100
|
const stateUrl = stateUrlOf(rec);
|
|
76
101
|
const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
|
|
77
102
|
.catch((e) => ({ ok: false, why: e.message }));
|
|
78
103
|
if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
|
|
104
|
+
await noteFailure(ctx.copyKv, handle, made.why);
|
|
105
|
+
log(`copy @${handle}: not made: ${made.why}`);
|
|
106
|
+
if (refusedSignIn(made.why)) {
|
|
107
|
+
await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
|
|
108
|
+
}
|
|
79
109
|
return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
|
|
80
110
|
} finally { await unlock(); }
|
|
81
111
|
}
|
|
@@ -107,7 +137,9 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
107
137
|
try { body = JSON.parse((await request.clone().text()) || '{}'); } catch { return j(400, { error: 'bad JSON' }); }
|
|
108
138
|
// The account: named, or the one browser account kept here for this WebID.
|
|
109
139
|
let handle = String(body.handle || '').toLowerCase();
|
|
110
|
-
|
|
140
|
+
// Read fresh: FediPod asks this the moment it has turned keeping on, and a
|
|
141
|
+
// row read a minute earlier would still say the account is not kept.
|
|
142
|
+
let rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
|
|
111
143
|
if (!handle) {
|
|
112
144
|
const rows = Object.entries(await ctx.listDirectory?.() || {})
|
|
113
145
|
.filter(([, r]) => r?.webId === webid && r.openedAt && r.keeper && !r.movedTo && !r.closedAt);
|
package/lib/server/embed.mjs
CHANGED
|
@@ -16,12 +16,12 @@ import path from 'node:path';
|
|
|
16
16
|
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
18
|
|
|
19
|
-
import { Agent } from '
|
|
20
|
-
import { RemotePod } from '../
|
|
19
|
+
import { Agent } from '../core/agent.mjs';
|
|
20
|
+
import { RemotePod } from '../core/remote.mjs';
|
|
21
21
|
import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
22
22
|
import { handleDelivery } from '../gateway/gateway-core.mjs';
|
|
23
|
-
import { writeJsonAtomic } from '../
|
|
24
|
-
import { buildAdminSurface } from '../
|
|
23
|
+
import { writeJsonAtomic } from '../shared/files.mjs';
|
|
24
|
+
import { buildAdminSurface } from '../surface/index.mjs';
|
|
25
25
|
import { FixedAuthorities } from '../shared/guard.mjs';
|
|
26
26
|
import { CONNECTION_PREFIX, fileVault, moveVault, podVault } from '../connections/vault.mjs';
|
|
27
27
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// files.mjs — writing a file without a window in which it is half there, and
|
|
2
|
+
// showing a path the way a person types it.
|
|
3
|
+
//
|
|
4
|
+
// The files written this way are the ones nothing can rebuild: keys.json is
|
|
5
|
+
// the private key remote servers have cached, and credential.json holds a
|
|
6
|
+
// secret a Solid server mints once and will not mint again. A plain
|
|
7
|
+
// writeFileSync truncates first, so a crash, a power loss or a full disk
|
|
8
|
+
// between truncate and write leaves an empty or half-written file where the
|
|
9
|
+
// identity used to be.
|
|
10
|
+
//
|
|
11
|
+
// rename(2) is atomic on POSIX, so a reader — or a backup running at the wrong
|
|
12
|
+
// moment — sees either the old file or the new one. fsync before it, so the
|
|
13
|
+
// content is on the platter and not merely in the page cache when the rename
|
|
14
|
+
// lands. Windows has no atomic rename over an existing file; there the copy
|
|
15
|
+
// path is still better than a truncate, and this is a best effort by design.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
export function writeFileAtomic(file, body, { mode = 0o600 } = {}) {
|
|
23
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
24
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
25
|
+
const fd = fs.openSync(tmp, 'w', mode);
|
|
26
|
+
try {
|
|
27
|
+
fs.writeFileSync(fd, body);
|
|
28
|
+
fs.fsyncSync(fd);
|
|
29
|
+
} finally {
|
|
30
|
+
fs.closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
fs.renameSync(tmp, file);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The same, for the JSON these files all happen to be.
|
|
36
|
+
export function writeJsonAtomic(file, obj, opts) {
|
|
37
|
+
writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n', opts);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A path for display: the home directory becomes `~`, a `file:` URL is shown
|
|
41
|
+
// as the path it is. Never used to build a path — `~` is the shell's, not the
|
|
42
|
+
// filesystem's, and anything that resolves one must expand it first.
|
|
43
|
+
export function tildify(p, homedir = os.homedir()) {
|
|
44
|
+
if (!p) return p;
|
|
45
|
+
let s = String(p);
|
|
46
|
+
if (s.startsWith('file://')) { try { s = fileURLToPath(s); } catch { return s; } }
|
|
47
|
+
if (s === homedir) return '~';
|
|
48
|
+
return s.startsWith(homedir + path.sep) ? '~' + s.slice(homedir.length) : s;
|
|
49
|
+
}
|
package/lib/shared/ua.mjs
CHANGED
|
@@ -27,3 +27,6 @@ try {
|
|
|
27
27
|
// Renamed 2026-07-31, once solidcommunity.net was back up and both actors there
|
|
28
28
|
// were retired: the name in their logs no longer has to match the name in here.
|
|
29
29
|
export const USER_AGENT = `fedipod/${version} (+https://github.com/jeff-zucker/FediPod)`;
|
|
30
|
+
// The same version, for anything else that reports it (the publisher's
|
|
31
|
+
// nodeinfo and status), read the same best-effort way.
|
|
32
|
+
export const AGENT_VERSION = version;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The identity's HTTP surface: the Mastodon client API and OAuth, the
|
|
2
|
+
// ActivityPub client-to-server door, nodeinfo, the owner's door and routes,
|
|
3
|
+
// and the pages and client served off disk. buildAdminSurface returns one
|
|
4
|
+
// request handler; whatever listens hands it requests.
|
|
5
|
+
|
|
6
|
+
export { buildAdminSurface } from './surface.mjs';
|
|
7
|
+
export { namedOrigin, secureOrigin, wsOrigins } from './origins.mjs';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// origins.mjs — the addresses an identity answers on, as a browser must be
|
|
2
2
|
// sent to them.
|
|
3
3
|
|
|
4
|
-
import { allowedAuthorities, hostLabel } from '
|
|
4
|
+
import { allowedAuthorities, hostLabel } from '../shared/guard.mjs';
|
|
5
5
|
|
|
6
6
|
// The origin a browser should be sent to for an identity. A browser keys
|
|
7
7
|
// storage per ORIGIN, so linking every identity at localhost:<port> files them
|
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
7
|
|
|
9
8
|
export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
|
|
10
|
-
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
9
|
+
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup, restart } = ctx; // eslint-disable-line no-unused-vars
|
|
11
10
|
const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
|
|
12
11
|
switch (p) {
|
|
13
12
|
// The inbox gateway lifecycle (opt-in). `configure` points at a
|
|
@@ -50,15 +49,10 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
50
49
|
const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'fedipod/';
|
|
51
50
|
return `${base}${root}ap/actor`;
|
|
52
51
|
};
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const child = spawn(process.execPath, process.argv.slice(1),
|
|
58
|
-
{ detached: true, stdio: 'ignore', env: process.env });
|
|
59
|
-
child.unref();
|
|
60
|
-
setTimeout(() => process.exit(0), 300);
|
|
61
|
-
}, 200);
|
|
52
|
+
// Fronting and un-fronting rename every id, which only a restart
|
|
53
|
+
// applies. A host that cannot restart itself refuses them.
|
|
54
|
+
const selfRestart = restart;
|
|
55
|
+
const noRestart = { error: 'this identity runs inside its pod server, which cannot restart for it — front it, or un-front it, from a standalone agent' };
|
|
62
56
|
if (body.action === 'configure') {
|
|
63
57
|
if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
|
|
64
58
|
if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
|
|
@@ -121,6 +115,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
121
115
|
if (agent.embedded) {
|
|
122
116
|
return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
|
|
123
117
|
}
|
|
118
|
+
if (body.fronted === true && !selfRestart) return json(res, 400, noRestart);
|
|
124
119
|
const named = !!String(body.handle || '').trim();
|
|
125
120
|
let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
|
|
126
121
|
if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
|
|
@@ -177,6 +172,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
177
172
|
if (body.action === 'forget') {
|
|
178
173
|
const wasLocked = g.mode === 'locked';
|
|
179
174
|
const wasFronted = !!g.frontActor;
|
|
175
|
+
if (wasFronted && !selfRestart) return json(res, 400, noRestart);
|
|
180
176
|
delete cfg.gateway; agent.store.setConfig(cfg);
|
|
181
177
|
if (agent.publisher) agent.publisher.config.gateway = undefined;
|
|
182
178
|
await agent.store.flush();
|