fedipod 1.40.0 → 1.43.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/deliver.mjs +11 -1
- package/lib/core/gateway-move.mjs +58 -0
- package/lib/core/intake/group.mjs +10 -11
- 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 +12 -3
- package/lib/gateway/front-core.mjs +16 -17
- package/lib/gateway/front-pages.mjs +31 -0
- package/lib/gateway/gateway-core.mjs +1 -1
- package/lib/gateway/held-mail.mjs +3 -0
- package/lib/gateway/keeper-session.mjs +10 -1
- package/lib/gateway/keeper.mjs +3 -0
- 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 +19 -720
- package/scripts/build-app.mjs +4 -0
- package/scripts/check-netlify-bundles.mjs +88 -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
|
@@ -1,58 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// at this one). The browser build has the same two halves in
|
|
5
|
-
// web/app/gateway-move.mjs; this is the Node one, which signs and delivers
|
|
6
|
-
// itself instead of going through a relay.
|
|
7
|
-
//
|
|
8
|
-
// 1. The old gateway is told the address moved (POST /api/move, proved with
|
|
9
|
-
// the pod session as attach was). From then on it serves the old actor
|
|
10
|
-
// as a moved stub — same key, old id, `movedTo` the new one.
|
|
11
|
-
// 2. A Move goes to every follower, FROM the old actor, signed under the
|
|
12
|
-
// old key id: the only signature a follower's server can verify against
|
|
13
|
-
// the old actor. Same key material; only the key id differs.
|
|
14
|
-
//
|
|
15
|
-
// Runs when the agent starts acting, and is safe to run again: a move left
|
|
16
|
-
// pending (the old gateway unreachable) is tried at the next start.
|
|
17
|
-
import { Deliverer } from '../core/deliver.mjs';
|
|
18
|
-
import { moveActivity } from '../core/wire.mjs';
|
|
19
|
-
|
|
20
|
-
export async function completeGatewayMove(agent, { makeDeliverer = (o) => new Deliverer(o) } = {}) {
|
|
21
|
-
const cfg = agent.store.getConfig();
|
|
22
|
-
const mv = cfg?.movedFrom;
|
|
23
|
-
if (!mv || mv.completedAt) return null;
|
|
24
|
-
const log = agent.log || (() => {});
|
|
25
|
-
const newActor = agent.urls.actor;
|
|
26
|
-
|
|
27
|
-
// 1. the old gateway
|
|
28
|
-
let told;
|
|
29
|
-
try {
|
|
30
|
-
told = await agent.remote.session.fetch(`${mv.gateway}/api/move`, {
|
|
31
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
32
|
-
body: JSON.stringify({ handle: mv.handle, movedTo: newActor }),
|
|
33
|
-
});
|
|
34
|
-
} catch (e) { told = { status: 0, statusText: e.message }; }
|
|
35
|
-
if (told.status !== 200) {
|
|
36
|
-
log(`gateway move: ${mv.gateway} did not mark @${mv.handle} as moved (${told.status || told.statusText}) — will retry at the next start`);
|
|
37
|
-
return { told: false };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 2. the Move, from the old address
|
|
41
|
-
const contacts = agent.store.getContacts();
|
|
42
|
-
const inboxes = [...new Set(contacts.followers.map((f) => f.sharedInbox || f.inbox).filter(Boolean))];
|
|
43
|
-
const activity = moveActivity({ actor: mv.actor }, newActor, Date.now());
|
|
44
|
-
const sender = makeDeliverer({
|
|
45
|
-
store: agent.store, rsaPrivate: agent.deliverer.rsaPrivate,
|
|
46
|
-
keyId: `${mv.actor}#main-key`, actorId: mv.actor, log, passive: true,
|
|
47
|
-
});
|
|
48
|
-
let sent = 0; let failed = 0;
|
|
49
|
-
for (const inbox of inboxes) {
|
|
50
|
-
try { await sender.deliverNow(inbox, activity); sent++; }
|
|
51
|
-
catch (e) { failed++; log(`gateway move: Move to ${inbox} failed: ${e.message}`); }
|
|
52
|
-
}
|
|
53
|
-
const done = { ...mv, completedAt: new Date().toISOString(), moveSent: sent, moveFailed: failed };
|
|
54
|
-
agent.store.setConfig({ ...agent.store.getConfig(), movedFrom: done });
|
|
55
|
-
await agent.store.flush?.();
|
|
56
|
-
log(`moved from ${mv.actor} to ${newActor}: Move sent to ${sent} inbox(es)${failed ? `, ${failed} failed` : ''}`);
|
|
57
|
-
return done;
|
|
58
|
-
}
|
|
1
|
+
// Finishing a move between gateways lives in lib/core/gateway-move.mjs; this
|
|
2
|
+
// path is kept for the callers that import it from here.
|
|
3
|
+
export { completeGatewayMove } from '../core/gateway-move.mjs';
|
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
|
@@ -149,9 +149,12 @@ export async function holds(kv, h, holder) {
|
|
|
149
149
|
export class CopyStorage {
|
|
150
150
|
// `podNames`: which of the pod-only documents to list (all, by default). The
|
|
151
151
|
// gateway's own agents need only the key.
|
|
152
|
-
|
|
152
|
+
// `fence`: the copy whose lease decides who may write this one. A forum's
|
|
153
|
+
// categories are fenced by the forum's copy, since one lease runs the forum.
|
|
154
|
+
constructor(kv, handle, { holder, pod = null, onRefused = null, podNames = null, fence = null }) {
|
|
153
155
|
this.kv = kv;
|
|
154
156
|
this.h = account(handle);
|
|
157
|
+
this.fence = fence ? account(fence) : this.h;
|
|
155
158
|
this.holder = holder;
|
|
156
159
|
this.pod = pod;
|
|
157
160
|
this.onRefused = onRefused;
|
|
@@ -186,7 +189,7 @@ export class CopyStorage {
|
|
|
186
189
|
|
|
187
190
|
// A refusal is an answer, not a hiccup: the lease is someone else's.
|
|
188
191
|
async _fenced() {
|
|
189
|
-
if (await holds(this.kv, this.
|
|
192
|
+
if (await holds(this.kv, this.fence, this.holder)) return null;
|
|
190
193
|
this.onRefused?.();
|
|
191
194
|
return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
|
|
192
195
|
}
|
|
@@ -263,7 +266,13 @@ export async function lockCopy(kv, h, { ms = 60_000, waitMs = 8_000, by = crypto
|
|
|
263
266
|
export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, log = () => {} }) {
|
|
264
267
|
if (await copyMeta(kv, h)) return { ok: true, already: true };
|
|
265
268
|
const podLease = new Lease({ url: stateUrl + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
|
|
266
|
-
if (!await podLease.acquire())
|
|
269
|
+
if (!await podLease.acquire()) {
|
|
270
|
+
// Held is another device acting on the pod; unreadable is the gateway's
|
|
271
|
+
// own identity refused or unanswered there, which no device has to do with.
|
|
272
|
+
return podLease.denied === 'unreadable'
|
|
273
|
+
? { ok: false, why: `the pod could not be read (${podLease.unreadable})` }
|
|
274
|
+
: { ok: false, why: 'the account is active on another device' };
|
|
275
|
+
}
|
|
267
276
|
const undo = async (why) => {
|
|
268
277
|
for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
|
|
269
278
|
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
|
};
|
|
@@ -809,13 +813,22 @@ async function route(request, ctx) {
|
|
|
809
813
|
const standing = await accountState(ctx, up.handle, rec);
|
|
810
814
|
// A browser account's mail waits here while its app is closed (held-mail.mjs).
|
|
811
815
|
const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
|
|
812
|
-
const { status, reason, content, bytes, type, notifies } = await handleDelivery(request, identFor(rec, policy),
|
|
816
|
+
const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
813
817
|
{ podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
|
|
814
818
|
fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
815
819
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
816
820
|
console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
|
|
817
821
|
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
818
822
|
if (held && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
|
|
823
|
+
// A forum fedipod.net keeps: the delivery is placed now, in this request,
|
|
824
|
+
// and the run that carries it is started. Whatever could not be placed
|
|
825
|
+
// here is still in the inbox for that run.
|
|
826
|
+
if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper) {
|
|
827
|
+
const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
|
|
828
|
+
.catch((e) => ({ placed: false, why: e.message }));
|
|
829
|
+
console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
|
|
830
|
+
if (placed.forum && ctx.startKeeper) await ctx.startKeeper(placed.forum).catch((e) => console.log(`keeper @${placed.forum}: not started (${e.message})`));
|
|
831
|
+
}
|
|
819
832
|
return { status, headers: {}, body: '' };
|
|
820
833
|
}
|
|
821
834
|
|
|
@@ -847,21 +860,7 @@ async function route(request, ctx) {
|
|
|
847
860
|
return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
848
861
|
}
|
|
849
862
|
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 } : {});
|
|
863
|
+
return ownerPostAtDoor(request, pathname, ctx, rec, up.handle, { cors, identFor, verifyPodToken, webidUnderPod });
|
|
865
864
|
}
|
|
866
865
|
// Its owner, signed in, reads every message the account produced (§5.1:
|
|
867
866
|
// 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
|
+
}
|
|
@@ -138,7 +138,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
138
138
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
139
139
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
140
140
|
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw),
|
|
141
|
-
type: activity.type, notifies: notifies(activity, ident) };
|
|
141
|
+
type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -166,6 +166,9 @@ export async function routeKeeperApi(request, pathname, ctx, deps) {
|
|
|
166
166
|
const next = { ...rec };
|
|
167
167
|
if (on) next.keeper = { webId: ctx.keeperWebId, at: new Date().toISOString() };
|
|
168
168
|
else delete next.keeper;
|
|
169
|
+
// A forum's category names the forum it belongs to, so a delivery to the
|
|
170
|
+
// category finds the forum's copy and lease.
|
|
171
|
+
if (typeof body.forum === 'string' && /^[a-z0-9][a-z0-9._-]{0,60}$/u.test(body.forum)) next.forum = body.forum;
|
|
169
172
|
await ctx.putDirectory(handle, next);
|
|
170
173
|
console.log(`keeper @${handle}: ${on ? 'on' : 'off'}, at the owner's word`);
|
|
171
174
|
return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
|
|
@@ -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
|
}
|
package/lib/gateway/keeper.mjs
CHANGED
|
@@ -33,6 +33,9 @@ export { keeperSession };
|
|
|
33
33
|
* drain reads it, as before.
|
|
34
34
|
*/
|
|
35
35
|
export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
|
|
36
|
+
// A forum, or one of its categories, is run whole by the forum's own code
|
|
37
|
+
// (ctx.forum: the forum package's gateway module), not by an account's.
|
|
38
|
+
if (ctx.forum?.isForumRow(rec)) return ctx.forum.keepOnce(ctx, handle, rec, { log, session });
|
|
36
39
|
const say = (m) => log(`keeper @${handle}: ${m}`);
|
|
37
40
|
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
38
41
|
if (at.skipped) return { skipped: at.skipped };
|
|
@@ -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);
|