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.
Files changed (65) hide show
  1. package/README.md +5 -0
  2. package/architecture.md +1 -3
  3. package/architecture.svg +93 -85
  4. package/gateway.md +7 -2
  5. package/lib/client/c2s.mjs +16 -5
  6. package/lib/client/masto/oauth.mjs +1 -1
  7. package/lib/connections/vault.mjs +1 -1
  8. package/lib/core/agent.mjs +720 -0
  9. package/lib/core/gateway-move.mjs +58 -0
  10. package/lib/core/keys.mjs +1 -1
  11. package/lib/core/lease.mjs +4 -3
  12. package/lib/core/publisher/index.mjs +4 -6
  13. package/lib/core/publisher/notes.mjs +73 -4
  14. package/lib/core/remote.mjs +54 -0
  15. package/lib/core/shapes/shapes-text.mjs +11 -3
  16. package/lib/core/wire.mjs +4 -1
  17. package/lib/device/admin/index.mjs +4 -11
  18. package/lib/device/admin/routes/setup.mjs +60 -5
  19. package/lib/device/admin/server.mjs +18 -5
  20. package/lib/device/cli/commands/account.mjs +1 -1
  21. package/lib/device/cli/commands/run.mjs +2 -2
  22. package/lib/device/cli/commands/service.mjs +1 -1
  23. package/lib/device/cli/commands/setup.mjs +1 -1
  24. package/lib/device/cli/commands/state.mjs +1 -1
  25. package/lib/device/cli/context.mjs +1 -1
  26. package/lib/{gateway → device}/directory.mjs +1 -1
  27. package/lib/device/gateway-move.mjs +3 -58
  28. package/lib/device/home.mjs +3 -50
  29. package/lib/{client → device}/localapi.mjs +2 -2
  30. package/lib/device/remote.mjs +3 -54
  31. package/lib/gateway/copy.mjs +7 -1
  32. package/lib/gateway/front-core.mjs +6 -16
  33. package/lib/gateway/front-pages.mjs +31 -0
  34. package/lib/gateway/keeper-session.mjs +10 -1
  35. package/lib/gateway/masto-gateway.mjs +61 -12
  36. package/lib/gateway/outbox-door.mjs +31 -0
  37. package/lib/gateway/state-api.mjs +39 -7
  38. package/lib/server/embed.mjs +4 -4
  39. package/lib/shared/files.mjs +49 -0
  40. package/lib/shared/ua.mjs +3 -0
  41. package/lib/surface/index.mjs +7 -0
  42. package/lib/{device/admin → surface}/origins.mjs +1 -1
  43. package/lib/{device/admin → surface}/routes/gateway.mjs +7 -11
  44. package/lib/{device/admin → surface}/routes/lifecycle.mjs +3 -3
  45. package/lib/{device/admin → surface}/routes/owner.mjs +4 -60
  46. package/lib/{device/admin → surface}/routes/social.mjs +1 -1
  47. package/lib/{device/admin → surface}/static.mjs +6 -1
  48. package/lib/{device/admin → surface}/surface.mjs +19 -16
  49. package/package.json +23 -5
  50. package/run-agent.mjs +18 -714
  51. package/scripts/build-app.mjs +4 -0
  52. package/scripts/check-netlify-bundles.mjs +84 -0
  53. package/scripts/check-pod-calls.mjs +42 -0
  54. package/scripts/vendor-sanitize.mjs +27 -0
  55. package/vendor/idp-grant.cjs +11 -5
  56. package/vendor/sanitize-html.cjs +11456 -0
  57. package/web/app/agent.mjs +4 -2
  58. package/web/app/copy-mode.mjs +27 -3
  59. package/web/app/dist/boot.js.map +1 -1
  60. package/web/app/dist/sw.js +110 -24
  61. package/web/app/dist/sw.js.map +4 -4
  62. package/web/app-signin/app-signin.mjs +2 -2
  63. package/web/app-signin/index.html +13 -5
  64. package/browser.svg +0 -72
  65. /package/lib/{device/admin → surface}/routes/connections.mjs +0 -0
@@ -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
+ }
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 '../device/home.mjs';
15
+ import { writeJsonAtomic } from '../shared/files.mjs';
16
16
 
17
17
  const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
18
18
 
@@ -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('lease unreadable — staying a viewer'); return false;
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
- const AGENT_VERSION = JSON.parse(fs.readFileSync(
35
- path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json'), 'utf8')).version;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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
- const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
212
- return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
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
- export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null, also = [], deliverTo = [] } = {}) {
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
- export const SHAPES_TTL = fs.readFileSync(
12
- path.join(path.dirname(fileURLToPath(import.meta.url)), 'activitystreams.ttl'), 'utf8',
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
- import sanitize from 'sanitize-html';
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
- // admin.mjs — loopback HTTP front: admin API + Mastodon client-API facade +
2
- // the bundled Phanpy UI served same-origin (no CORS, no mixed content — the
3
- // role data-kitchen's router plays for the in-app pane). Loopback-bound; the
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 './surface.mjs';
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 '../../../gateway/directory.mjs';
19
- import { localFetch } from '../../../client/localapi.mjs';
20
- import { projectRoot, SETUP_PAGE } from '../static.mjs';
21
- import { secureOrigin } from '../origins.mjs';
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 '../../gateway/directory.mjs';
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 './static.mjs';
17
- import { secureOrigin } from './origins.mjs';
18
- import { buildAdminSurface } from './surface.mjs';
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 '../../../client/localapi.mjs';
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 '../../../client/localapi.mjs';
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('../../../../lib/gateway/directory.mjs', import.meta.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 '../../../client/localapi.mjs';
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 '../../../client/localapi.mjs';
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 '../../../client/localapi.mjs';
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 '../../../lib/client/localapi.mjs';
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 '../client/localapi.mjs';
16
+ import { localFetch } from './localapi.mjs';
17
17
 
18
18
  export const DIRECTORY_PORT = 8030;
19
19
 
@@ -1,58 +1,3 @@
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 '../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';