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
package/lib/core/deliver.mjs
CHANGED
|
@@ -61,9 +61,14 @@ export class Deliverer {
|
|
|
61
61
|
// passive: signing-only (viewer-mode agents) — no queue drain timer, so a
|
|
62
62
|
// read-only agent never mutates shared delivery state. startQueue() flips
|
|
63
63
|
// it live when a viewer is promoted to active.
|
|
64
|
+
// defer: nothing is sent now; every delivery goes on the queue, signed, for
|
|
65
|
+
// drainQueue() to make. For a process with seconds to answer — a door
|
|
66
|
+
// placing a post as it lands — which records what to send and leaves the
|
|
67
|
+
// sending to the run it starts.
|
|
64
68
|
constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
|
|
65
|
-
log = console.log, passive = false, onGone = null }) {
|
|
69
|
+
log = console.log, passive = false, defer = false, onGone = null }) {
|
|
66
70
|
this.store = store;
|
|
71
|
+
this.defer = defer;
|
|
67
72
|
// Called after followers at a gone inbox are dropped: the agent republishes
|
|
68
73
|
// its followers collection.
|
|
69
74
|
this.onGone = onGone;
|
|
@@ -205,6 +210,7 @@ export class Deliverer {
|
|
|
205
210
|
// Proved before anything else, so the copy that goes on the queue is the
|
|
206
211
|
// copy that was signed — a retry days later must not post a bare activity.
|
|
207
212
|
const signed = await this.proofed(activity);
|
|
213
|
+
if (this.defer) { this._enqueue({ inbox, activity: signed, attempts: 0, nextAt: Date.now() }); return; }
|
|
208
214
|
if (this._queueIfCooling(inbox, signed)) return;
|
|
209
215
|
const [result] = await this.deliverManyNow([{ inbox, activity: signed }]);
|
|
210
216
|
await this._settle(inbox, signed, result);
|
|
@@ -273,6 +279,10 @@ export class Deliverer {
|
|
|
273
279
|
// by the next.
|
|
274
280
|
const targets = [...new Set(inboxes)].filter((inbox) => !this._blockedInbox(inbox))
|
|
275
281
|
.map((inbox) => ({ inbox, activity: signed }));
|
|
282
|
+
if (this.defer) {
|
|
283
|
+
for (const t of targets) this._enqueue({ inbox: t.inbox, activity: signed, attempts: 0, nextAt: Date.now() });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
276
286
|
for (let i = 0; i < targets.length; i += this.batchSize) {
|
|
277
287
|
const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
|
|
278
288
|
if (!chunk.length) continue;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// gateway-move.mjs — the second half of a DeviceAgent moving an address from
|
|
2
|
+
// one gateway to another (lib/device/setup.mjs does the first, when a pod
|
|
3
|
+
// that already holds an account at another gateway is set up with an address
|
|
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 './deliver.mjs';
|
|
18
|
+
import { moveActivity } from './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
|
+
}
|
|
@@ -171,6 +171,16 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
171
171
|
serial: intake.serial++,
|
|
172
172
|
audience: intake.urls.actor,
|
|
173
173
|
});
|
|
174
|
+
// What the group is about to carry is what the group's conversation holds.
|
|
175
|
+
// A forum places the post in its topic here, BEFORE the carry: the topic is
|
|
176
|
+
// what the category vouches for (FEP-7888), and a place that will not take
|
|
177
|
+
// the post — a locked topic — answers false and nothing is carried. Both
|
|
178
|
+
// the Announce and what its author sent: a forum reads the author's own
|
|
179
|
+
// activity — the name they gave the topic they were opening is on that one.
|
|
180
|
+
if (intake.onCarry) {
|
|
181
|
+
const placed = await intake.onCarry({ noteId, actor: s.actor, activity: act, sent: activity });
|
|
182
|
+
if (placed === false) { intake.log(`not amplified — ${noteId} has no place here`); return; }
|
|
183
|
+
}
|
|
174
184
|
await intake.deliverer.deliverToAll(inboxes, act);
|
|
175
185
|
// Marked carried before recorded: a failed outbox write costs one missing
|
|
176
186
|
// entry, a failed status write would carry the same post twice.
|
|
@@ -180,17 +190,6 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
180
190
|
if (!intake.config.private) await intake.publisher.recordOutbox(act);
|
|
181
191
|
intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
|
|
182
192
|
intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
|
|
183
|
-
// What the group carried is what the group's conversation holds. A forum
|
|
184
|
-
// places the post in its topic here — after the carry, so only what the
|
|
185
|
-
// category vouched for is in the topic (FEP-7888 ownership). Its failure
|
|
186
|
-
// never undoes the carry.
|
|
187
|
-
if (intake.onCarried) {
|
|
188
|
-
// Both: what the group said (the Announce) and what its author sent. A
|
|
189
|
-
// forum reads the author's own activity — the name they gave the topic
|
|
190
|
-
// they were opening is on that one, and on no other.
|
|
191
|
-
await intake.onCarried({ noteId, actor: s.actor, activity: act, sent: activity })
|
|
192
|
-
.catch(e => intake.log(`after the carry of ${noteId}: ${e.message}`));
|
|
193
|
-
}
|
|
194
193
|
// The same carry, shown natively to the group's Bluesky followers.
|
|
195
194
|
await intake.bskyGroup?.mirrorCarry(s)
|
|
196
195
|
.catch(e => intake.log(`bluesky mirror of the carry failed: ${e.message}`));
|
package/lib/core/keys.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { writeJsonAtomic } from '../
|
|
15
|
+
import { writeJsonAtomic } from '../shared/files.mjs';
|
|
16
16
|
|
|
17
17
|
const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
18
18
|
|
package/lib/core/lease.mjs
CHANGED
|
@@ -40,6 +40,7 @@ export class Lease {
|
|
|
40
40
|
this.timer = null;
|
|
41
41
|
this.heldUntil = 0; // wall-clock end of the lease we last wrote
|
|
42
42
|
this.denied = null; // why the last acquire() said no: 'unreadable' | 'held'
|
|
43
|
+
this.unreadable = null; // and, when unreadable, what the read got: "HTTP 403", or the error
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
// null means the lease document is NOT THERE — nobody holds it. UNREADABLE
|
|
@@ -52,9 +53,9 @@ export class Lease {
|
|
|
52
53
|
try {
|
|
53
54
|
res = await this.fetchImpl(this.url, { headers: { accept: 'application/json' } });
|
|
54
55
|
if (res.status === 404 || res.status === 410) return null;
|
|
55
|
-
if (res.status >= 400) return UNREADABLE;
|
|
56
|
+
if (res.status >= 400) { this.unreadable = `HTTP ${res.status}`; return UNREADABLE; }
|
|
56
57
|
body = await res.text();
|
|
57
|
-
} catch { return UNREADABLE; }
|
|
58
|
+
} catch (e) { this.unreadable = e.message; return UNREADABLE; }
|
|
58
59
|
this.etag = res.headers?.get?.('etag') || null;
|
|
59
60
|
// A 200 carrying garbage — a half-written PUT, a proxy error page served
|
|
60
61
|
// as 200 — is not a lease anybody holds, and overwriting it is the repair.
|
|
@@ -87,7 +88,7 @@ export class Lease {
|
|
|
87
88
|
// an inconvenience; two agents both draining is the destructive one.
|
|
88
89
|
if (cur === UNREADABLE) {
|
|
89
90
|
this.denied = 'unreadable';
|
|
90
|
-
this.log(
|
|
91
|
+
this.log(`lease unreadable (${this.unreadable}) — staying a viewer`); return false;
|
|
91
92
|
}
|
|
92
93
|
if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) { this.denied = 'held'; return false; }
|
|
93
94
|
const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
|
|
@@ -9,12 +9,9 @@
|
|
|
9
9
|
// up), questions.mjs (polls) — each a set of functions taking the Publisher
|
|
10
10
|
// as their first argument, reached here through one-line delegations.
|
|
11
11
|
|
|
12
|
-
import fs from 'node:fs';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
12
|
import crypto from 'node:crypto';
|
|
16
13
|
import * as wire from '../wire.mjs';
|
|
17
|
-
import { USER_AGENT } from '../../shared/ua.mjs';
|
|
14
|
+
import { USER_AGENT, AGENT_VERSION } from '../../shared/ua.mjs';
|
|
18
15
|
import { HTTP_TIMEOUT_MS } from '../../shared/safefetch.mjs';
|
|
19
16
|
import * as containers from '../../pod/containers.mjs';
|
|
20
17
|
import * as discovery from '../../pod/discovery.mjs';
|
|
@@ -31,8 +28,9 @@ import * as questions from './questions.mjs';
|
|
|
31
28
|
import * as own from './own.mjs';
|
|
32
29
|
import { recordPlace } from '../place.mjs';
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
// The version is read where a bundled deploy (the Netlify functions) cannot
|
|
32
|
+
// break it: a package.json that is not beside the bundle is not a reason for
|
|
33
|
+
// the gateway to fail to start (2026-09-26).
|
|
36
34
|
|
|
37
35
|
export class Publisher {
|
|
38
36
|
constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
@@ -203,13 +203,45 @@ export async function updateObject(publisher, s, patch, { updated = new Date().t
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
// What the author's own timeline shows for an object: its content, else its
|
|
206
|
-
// text under one of the names other vocabularies use, else a link to it.
|
|
206
|
+
// text under one of the names other vocabularies use, else a link to it. An
|
|
207
|
+
// annotation's text is preceded by a link to the page it is on and the words
|
|
208
|
+
// it was made on, quoted.
|
|
207
209
|
export function rowContent(obj) {
|
|
210
|
+
const text = objectText(obj);
|
|
211
|
+
const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
212
|
+
const on = annotationTarget(obj);
|
|
213
|
+
const link = on.page ? `<a href="${esc(on.page)}">${esc(on.page)}</a>` : '';
|
|
214
|
+
const context = on.exact
|
|
215
|
+
? `<p>${link ? `text from ${link}:` : 'text:'}</p><blockquote><p>${esc(on.exact)}</p></blockquote>`
|
|
216
|
+
: (link ? `<p>${link}</p>` : '');
|
|
217
|
+
if (text) return context ? `${context}<p>comment:</p>${text}` : text;
|
|
218
|
+
return context || `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function objectText(obj) {
|
|
208
222
|
if (typeof obj?.content === 'string' && obj.content.trim()) return wire.sanitizeHtml(obj.content);
|
|
209
223
|
const plain = [obj?.bodyValue, obj?.name, obj?.summary].find((v) => typeof v === 'string' && v.trim());
|
|
210
224
|
if (plain) return wire.contentHtml(plain);
|
|
211
|
-
|
|
212
|
-
|
|
225
|
+
// A Web Annotation's textual bodies, as a plain or an HTML string, or a
|
|
226
|
+
// JSON-LD value.
|
|
227
|
+
const bodies = [].concat(obj?.body || []).map((b) => (b && typeof b === 'object' && b.purpose !== 'tagging'
|
|
228
|
+
? (b.value && typeof b.value === 'object' ? b.value['@value'] : b.value) : null))
|
|
229
|
+
.filter((v) => typeof v === 'string' && v.trim());
|
|
230
|
+
return bodies.map((v) => (/<[a-z]/iu.test(v) ? wire.sanitizeHtml(v) : wire.contentHtml(v))).join('');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// An annotation's target: the page (its source, or the target itself) and the
|
|
234
|
+
// words quoted by a TextQuoteSelector, each a plain string or a JSON-LD value.
|
|
235
|
+
function annotationTarget(obj) {
|
|
236
|
+
const val = (v) => (v && typeof v === 'object' ? v['@value'] ?? v.id : v);
|
|
237
|
+
for (const t of [].concat(obj?.target || [])) {
|
|
238
|
+
const page = val(typeof t === 'string' ? t : (t?.source ?? t?.id));
|
|
239
|
+
const quote = [].concat(t?.selector || []).find((sel) => sel?.type === 'TextQuoteSelector');
|
|
240
|
+
const exact = val(quote?.exact);
|
|
241
|
+
const ok = typeof page === 'string' && /^https?:\/\//u.test(page);
|
|
242
|
+
if (ok || exact) return { page: ok ? page : null, exact: typeof exact === 'string' && exact.trim() ? exact.trim() : null };
|
|
243
|
+
}
|
|
244
|
+
return { page: null, exact: null };
|
|
213
245
|
}
|
|
214
246
|
|
|
215
247
|
// `quote`: the post this one quotes, `{ id, actor }` — the quoted author is
|
|
@@ -329,7 +361,12 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
329
361
|
// An object already living on this pod (an id under it) is not copied: the
|
|
330
362
|
// Create names it where it is. Anything else is written into the notes
|
|
331
363
|
// container under the client's slug when it is free, else a minted one.
|
|
332
|
-
|
|
364
|
+
//
|
|
365
|
+
// `unaddressed`: an object its client addressed to nobody, as dokieli sends
|
|
366
|
+
// an annotation. It is kept as sent in the notes container, readable at its
|
|
367
|
+
// address, and delivered to nobody; only its owner's outbox lists it.
|
|
368
|
+
export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null, also = [], deliverTo = [], unaddressed = false } = {}) {
|
|
369
|
+
if (unaddressed) return publishUnaddressed(publisher, object, wanted);
|
|
333
370
|
const { urls } = publisher;
|
|
334
371
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
335
372
|
if (priv) {
|
|
@@ -384,6 +421,38 @@ export async function publishObject(publisher, object, { visibility = 'public',
|
|
|
384
421
|
return { id, createId, copied: !ownId };
|
|
385
422
|
}
|
|
386
423
|
|
|
424
|
+
async function publishUnaddressed(publisher, object, wanted) {
|
|
425
|
+
const { urls } = publisher;
|
|
426
|
+
const published = new Date().toISOString();
|
|
427
|
+
const pod = publisher.config?.remotePod || urls.base;
|
|
428
|
+
const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
|
|
429
|
+
? object.id : null;
|
|
430
|
+
const name = await slugFor(publisher, urls.notes, wanted, published);
|
|
431
|
+
const id = ownId || urls.notes + name;
|
|
432
|
+
let doc = null;
|
|
433
|
+
if (!ownId) {
|
|
434
|
+
const { bto, bcc, ...sent } = object; // eslint-disable-line no-unused-vars
|
|
435
|
+
doc = { ...sent, id, attributedTo: urls.actor, published: object.published || published };
|
|
436
|
+
if (!doc['@context']) doc['@context'] = wire.AS_CTX;
|
|
437
|
+
if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
|
|
438
|
+
await podNotes.write(publisher.remote, id, doc);
|
|
439
|
+
}
|
|
440
|
+
const row = doc || object;
|
|
441
|
+
publisher.store.addStatus({
|
|
442
|
+
noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
|
|
443
|
+
kind: 'post', slug: name, visibility: 'direct',
|
|
444
|
+
});
|
|
445
|
+
if (await publisher.store.commit?.() === false) publisher.log(`object kept but its timeline row was refused: ${id}`);
|
|
446
|
+
const create = {
|
|
447
|
+
'@context': wire.AS_CTX, id: wire.createActivityId(ownId ? urls.notes + name : id), type: 'Create',
|
|
448
|
+
actor: urls.actor, published: row.published || published, object: doc || id,
|
|
449
|
+
};
|
|
450
|
+
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
451
|
+
publisher.recordOwn?.(create);
|
|
452
|
+
publisher.log(`${row.type || 'object'} kept, addressed to nobody: ${id}`);
|
|
453
|
+
return { id, createId: create.id, copied: !ownId };
|
|
454
|
+
}
|
|
455
|
+
|
|
387
456
|
// What the author's timeline row keeps of a note's tags: the people it
|
|
388
457
|
// mentions and the hashtags it carries, each as the client-facing shape.
|
|
389
458
|
export function rowTags(note) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// remote.mjs — the Node agent's pod transport: CSS client-credentials + DPoP
|
|
2
|
+
// (vendor/idp-grant.cjs, extracted from data-kitchen's "remember this IdP"
|
|
3
|
+
// machinery — plain Node, no Electron).
|
|
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
|
+
}
|
|
@@ -8,6 +8,14 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
)
|
|
11
|
+
// Beside this file, or — in a bundled deploy, where this file's own location
|
|
12
|
+
// is the bundle's — at its place in the project, which the Netlify functions
|
|
13
|
+
// ship (netlify.toml: included_files). The shapes decide nothing, so if
|
|
14
|
+
// neither is there they are left out rather than the process failing to
|
|
15
|
+
// start (2026-09-26).
|
|
16
|
+
const candidates = [
|
|
17
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), 'activitystreams.ttl'),
|
|
18
|
+
path.join(process.cwd(), 'lib/core/shapes/activitystreams.ttl'),
|
|
19
|
+
];
|
|
20
|
+
const found = candidates.find((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
|
|
21
|
+
export const SHAPES_TTL = found ? fs.readFileSync(found, 'utf8') : '';
|
package/lib/core/wire.mjs
CHANGED
|
@@ -495,7 +495,10 @@ export function nodeinfoDoc({ version, localPosts = 0 }) {
|
|
|
495
495
|
// only line of defence, so it runs on a real HTML parser (sanitize-html →
|
|
496
496
|
// htmlparser2) rather than pattern-matching: allowlisted tags and
|
|
497
497
|
// attributes, safe URL schemes, and script/style dropped with their text.
|
|
498
|
-
|
|
498
|
+
// Packed with its parser into one file (scripts/vendor-sanitize.mjs): the
|
|
499
|
+
// parser ships only as an ES module, which the Node running the Netlify
|
|
500
|
+
// functions will not require() from sanitize-html.
|
|
501
|
+
import sanitize from '../../vendor/sanitize-html.cjs';
|
|
499
502
|
|
|
500
503
|
const ALLOWED_TAGS = ['p', 'br', 'a', 'span', 'em', 'strong', 'b', 'i', 'u', 'del',
|
|
501
504
|
'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4'];
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
// gate (vendor/gate.cjs) engages only when a token is configured, exactly
|
|
5
|
-
// its standalone behavior.
|
|
6
|
-
//
|
|
7
|
-
// The surface is surface.mjs, its routes are the modules under routes/, the
|
|
8
|
-
// static serving is static.mjs, the listener is server.mjs. This file is the
|
|
9
|
-
// door the rest of the agent imports through.
|
|
1
|
+
// The DeviceAgent's door to the surface: the surface itself is
|
|
2
|
+
// lib/surface/, the listener is server.mjs beside this file, and the setup
|
|
3
|
+
// and process routes are routes/setup.mjs.
|
|
10
4
|
|
|
11
|
-
export { buildAdminSurface } from '
|
|
5
|
+
export { buildAdminSurface, namedOrigin, secureOrigin, wsOrigins } from '../../surface/index.mjs';
|
|
12
6
|
export { startAdmin } from './server.mjs';
|
|
13
|
-
export { namedOrigin, secureOrigin, wsOrigins } from './origins.mjs';
|
|
@@ -8,17 +8,17 @@ import os from 'node:os';
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { pathToFileURL } from 'node:url';
|
|
10
10
|
import { isCrossSiteNavigation } from '../../../shared/guard.mjs';
|
|
11
|
-
import { identityHomes, rootOf, tildify, writeJsonAtomic } from '../../home.mjs';
|
|
11
|
+
import { identityHomes, rootOf, tildify, writeJsonAtomic, defaultProfile } from '../../home.mjs';
|
|
12
12
|
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
|
|
13
13
|
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
14
14
|
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
|
|
15
15
|
import { podLayout } from '../../../pod/root.mjs';
|
|
16
16
|
import { chosenRoot, publicIndexKnown } from '../../../core/place.mjs';
|
|
17
17
|
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
18
|
-
import { yieldDirectory } from '
|
|
19
|
-
import { localFetch } from '
|
|
20
|
-
import { projectRoot, SETUP_PAGE } from '
|
|
21
|
-
import { secureOrigin } from '
|
|
18
|
+
import { yieldDirectory } from '../../directory.mjs';
|
|
19
|
+
import { localFetch } from '../../localapi.mjs';
|
|
20
|
+
import { projectRoot, SETUP_PAGE } from '../../../surface/static.mjs';
|
|
21
|
+
import { secureOrigin } from '../../../surface/origins.mjs';
|
|
22
22
|
|
|
23
23
|
// The signup carry-over: gateway and identity parameters a signup page
|
|
24
24
|
// recorded for this install (written by the installer, consumed by the first
|
|
@@ -46,6 +46,61 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
|
|
|
46
46
|
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
47
47
|
const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
|
|
48
48
|
|
|
49
|
+
// The other identities on this machine, so the page can link to each one.
|
|
50
|
+
// Their `agent.json` is the only file read — a port and a handle. A
|
|
51
|
+
// sibling's credential and keys are never opened; anything else shown
|
|
52
|
+
// here comes from that agent answering /status for itself.
|
|
53
|
+
if (req.method === 'GET' && p === '/profiles') {
|
|
54
|
+
const here = path.resolve(agent.home || '');
|
|
55
|
+
// Which identity a plain command means: the last one started. A
|
|
56
|
+
// different question from `current`, which is the page you are on.
|
|
57
|
+
const wasLast = defaultProfile(rootOf(here));
|
|
58
|
+
const rows = await Promise.all(identityHomes(rootOf(here)).map(async ({ name, dir }) => {
|
|
59
|
+
// agent.json holds a port AND a handle — setup records both, exactly
|
|
60
|
+
// so the named origin can be built before that agent has said a word.
|
|
61
|
+
let rec = {};
|
|
62
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')) || {}; } catch {}
|
|
63
|
+
const port = rec.port || null;
|
|
64
|
+
// A directory under profiles/ is not an identity until it holds a
|
|
65
|
+
// credential or has run somewhere. A half-finished setup leaves one
|
|
66
|
+
// behind, and listing it offers a page that cannot exist yet.
|
|
67
|
+
if (!port && !fs.existsSync(path.join(dir, 'credential.json'))) return null;
|
|
68
|
+
const current = path.resolve(dir) === here;
|
|
69
|
+
const live = current ? agent.status() : port
|
|
70
|
+
? await localFetch(agent.home, port, '/status', { timeout: 1200 })
|
|
71
|
+
.then(r => r.json()).catch(() => null)
|
|
72
|
+
: null;
|
|
73
|
+
// The handle is right here — spending it on the origin is the whole
|
|
74
|
+
// point of having asked that agent who it is. A stopped one never
|
|
75
|
+
// answers, so its recorded handle stands in: the link has to be named
|
|
76
|
+
// before you get there, or starting it lands you on the shared origin.
|
|
77
|
+
const handle = live?.handle || rec.handle || null;
|
|
78
|
+
const origin = port
|
|
79
|
+
? secureOrigin(handle, port)
|
|
80
|
+
: null;
|
|
81
|
+
// The fediverse address, assembled from the two things /status has:
|
|
82
|
+
// the handle, and the pod host its actor URL sits on.
|
|
83
|
+
let address = null;
|
|
84
|
+
if (live?.handle && live?.actor) {
|
|
85
|
+
try {
|
|
86
|
+
const front = live.actor.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
|
|
87
|
+
address = `${front || live.handle}@${new URL(live.actor).host}`;
|
|
88
|
+
} catch { /* not a URL yet */ }
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
name, port, current,
|
|
92
|
+
admin: origin ? `${origin}/admin/` : null,
|
|
93
|
+
app: origin ? `${origin}/` : null,
|
|
94
|
+
handle,
|
|
95
|
+
address,
|
|
96
|
+
lastUsed: name === wasLast,
|
|
97
|
+
kind: live?.kind || null,
|
|
98
|
+
mode: live ? live.mode : null,
|
|
99
|
+
};
|
|
100
|
+
}));
|
|
101
|
+
return json(res, 200, { identities: rows.filter(Boolean) });
|
|
102
|
+
}
|
|
103
|
+
|
|
49
104
|
// ---- setup and configuration, for the pages under /admin/ ----
|
|
50
105
|
// These answer for a group too: a group is set up in the browser like
|
|
51
106
|
// anything else, and it has a display name to change.
|
|
@@ -7,15 +7,27 @@ import fs from 'node:fs';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
10
11
|
import { Authorities } from '../../shared/guard.mjs';
|
|
11
12
|
import { rootOf } from '../home.mjs';
|
|
12
13
|
import { portFree } from '../ports.mjs';
|
|
13
|
-
import { claimDirectory, yieldDirectory } from '
|
|
14
|
+
import { claimDirectory, yieldDirectory } from '../directory.mjs';
|
|
14
15
|
import { ensureTrustedTls } from '../certs.mjs';
|
|
15
16
|
import { localVersion } from '../update.mjs';
|
|
16
|
-
import { projectRoot } from '
|
|
17
|
-
import { secureOrigin } from '
|
|
18
|
-
import { buildAdminSurface } from '
|
|
17
|
+
import { projectRoot } from '../../surface/static.mjs';
|
|
18
|
+
import { secureOrigin } from '../../surface/origins.mjs';
|
|
19
|
+
import { buildAdminSurface } from '../../surface/surface.mjs';
|
|
20
|
+
import * as setup from './routes/setup.mjs';
|
|
21
|
+
|
|
22
|
+
// The reply first, the restart a beat later — same shape as /update.
|
|
23
|
+
const selfRestart = (why, log) => setTimeout(() => {
|
|
24
|
+
log(why);
|
|
25
|
+
if (process.env.INVOCATION_ID) { process.exit(1); return; } // systemd: Restart=on-failure respawns
|
|
26
|
+
const child = spawn(process.execPath, process.argv.slice(1),
|
|
27
|
+
{ detached: true, stdio: 'ignore', env: process.env });
|
|
28
|
+
child.unref();
|
|
29
|
+
setTimeout(() => process.exit(0), 300);
|
|
30
|
+
}, 200);
|
|
19
31
|
|
|
20
32
|
const require = createRequire(import.meta.url);
|
|
21
33
|
const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
|
|
@@ -30,7 +42,8 @@ export function startAdmin({ port, gateToken, agent, log = console.log, handle =
|
|
|
30
42
|
// The https listener's port joins the authority set: same names, second port.
|
|
31
43
|
const allowed = new Authorities(port, handle);
|
|
32
44
|
agent.authorities = allowed;
|
|
33
|
-
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk
|
|
45
|
+
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk,
|
|
46
|
+
extraRoutes: [setup], restart: (why) => selfRestart(why, log) });
|
|
34
47
|
|
|
35
48
|
// Loopback both ways: the canonical URL is https://localhost:<port>/, and
|
|
36
49
|
// "localhost" resolves to ::1 on many systems before falling back to IPv4 —
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { localFetch } from '
|
|
7
|
+
import { localFetch } from '../../localapi.mjs';
|
|
8
8
|
import { args, cmd, flag, has, HOME, PORT, requireIdentity, ask, endAsking, finish } from '../context.mjs';
|
|
9
9
|
|
|
10
10
|
export async function parkRevive() {
|
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { identityHomes, rootOf, profileHome, recordLastUsed } from '../../home.mjs';
|
|
7
7
|
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
8
|
-
import { localFetch } from '
|
|
8
|
+
import { localFetch } from '../../localapi.mjs';
|
|
9
9
|
import { flag, has, portFlag, PROFILE, AP_ROOT, DEFAULT_ISSUE, HOME, PORT, recordedAgent, recordedPort, recordAgent, useProfile, requireHandle, requireIdentity, ask, endAsking, agentOn, openBrowser } from '../context.mjs';
|
|
10
10
|
|
|
11
11
|
export async function up() {
|
|
@@ -63,7 +63,7 @@ if (!await portFree(preferred)) {
|
|
|
63
63
|
// owner of its port. Anything else is simply not this port; walking on is
|
|
64
64
|
// what "occupied" has always meant here.
|
|
65
65
|
if (!already) {
|
|
66
|
-
const { yieldDirectory } = await import(new URL('
|
|
66
|
+
const { yieldDirectory } = await import(new URL('../../directory.mjs', import.meta.url));
|
|
67
67
|
if (!await yieldDirectory(preferred, { portFree })) {
|
|
68
68
|
port = await freePortFrom(preferred + 1);
|
|
69
69
|
// The shared helper returns null; the message is this command's to write.
|
|
@@ -6,7 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { identityHomes } from '../../home.mjs';
|
|
8
8
|
import { portFree } from '../../ports.mjs';
|
|
9
|
-
import { localFetch } from '
|
|
9
|
+
import { localFetch } from '../../localapi.mjs';
|
|
10
10
|
import { cmd, has, AP_ROOT, HOME, agentOn } from '../context.mjs';
|
|
11
11
|
|
|
12
12
|
export async function update() {
|
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
import { tildify, rootOf, recordLastUsed, writeJsonAtomic } from '../../home.mjs';
|
|
8
8
|
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
9
|
-
import { localFetch } from '
|
|
9
|
+
import { localFetch } from '../../localapi.mjs';
|
|
10
10
|
import { args, flag, has, PROFILE, AP_ROOT, HOME, PORT, recordAgent, useProfile, requireHandle, requireIdentity, askHidden, ask, endAsking, openBrowser, finish, IDENTITY_FLAGS, refuseExistingIdentity, runBrowserSetup } from '../context.mjs';
|
|
11
11
|
|
|
12
12
|
export async function setup() {
|
|
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
import { identityHomes, isLegacyRoot, CURRENT_ROOT, tildify, defaultProfile, profileHome, rootHoldsIdentity, recordLastUsed, writeJsonAtomic } from '../../home.mjs';
|
|
10
10
|
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
11
|
-
import { localFetch } from '
|
|
11
|
+
import { localFetch } from '../../localapi.mjs';
|
|
12
12
|
import { args, flag, has, PROFILE, AP_ROOT, HOME, PORT, requireHandle, requireIdentity, somethingOn, agentOn, isInside } from '../context.mjs';
|
|
13
13
|
|
|
14
14
|
export async function state() {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// reassigns them and every command module sees the new value.
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
-
import { localFetch } from '
|
|
7
|
+
import { localFetch } from '../localapi.mjs';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import net from 'node:net';
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// whole mechanism (the test suite sets it, so throwaway agents do not fight
|
|
14
14
|
// over a real machine's door).
|
|
15
15
|
import https from 'node:https';
|
|
16
|
-
import { localFetch } from '
|
|
16
|
+
import { localFetch } from './localapi.mjs';
|
|
17
17
|
|
|
18
18
|
export const DIRECTORY_PORT = 8030;
|
|
19
19
|
|