fedipod 1.6.0 → 1.7.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.
@@ -0,0 +1,99 @@
1
+ # The DeviceAgent
2
+
3
+ FediPod can also run as a program on your own machine, in front of the same
4
+ kind of pod. It does everything the browser version at fedipod.net does, plus
5
+ what a browser tab cannot: it keeps running while no tab is open, so scheduled
6
+ posts go out and push notifications reach you; it serves the Mastodon streaming
7
+ API, so clients update live; any Mastodon client, phone app or desktop, can
8
+ connect to it; and it can host a [group](groups.md).
9
+
10
+ ## Requirements
11
+
12
+ - Node 20 or newer.
13
+ - A Solid pod, either with a host name of its own, such as
14
+ `https://alice.solidcommunity.net/`, or on a suffix-based host, such as
15
+ `https://server.example/alice/`. A pod at its own host carries its address on
16
+ the pod; a pod on a suffix-based host takes its address at a gateway,
17
+ `@handle@fedipod.net`, with the posts, key and data staying on the pod.
18
+ - Followers-only and direct posts need a pod that enforces WAC access control;
19
+ on one that does not, the composer refuses those two and says why.
20
+ - While the agent is off, your mail waits on your pod's host. Run it as a
21
+ service, or attach to a gateway, so it does not pile up there.
22
+
23
+ ## Installing
24
+
25
+ ```
26
+ npm install -g fedipod
27
+ ```
28
+
29
+ ## Running
30
+
31
+ Run `fedipod start`. Add a port to change the local agent's port, for example
32
+ `fedipod start --port 8081`; the default is 8030. Then point any browser at
33
+ `https://localhost:8030`, or the port you chose, and the setup pages take it
34
+ from there.
35
+
36
+ ## Running as a service
37
+
38
+ ```
39
+ fedipod install-service
40
+ ```
41
+
42
+ It registers every identity on this machine, one service each, so all of your
43
+ actors start at boot. An identity running in a terminal is stopped and taken
44
+ over by its service. `fedipod uninstall-service` reverses it.
45
+
46
+ ## Managing
47
+
48
+ Posts, logs, parking, moving, transferring and the rest are on the
49
+ [admin interface](gui.md); starting, stopping and what a page cannot do are in
50
+ [CLI admin](cli.md). The admin tools also create other actors, groups or
51
+ persons. You may have as many as you want on one machine, each with a pod of
52
+ its own.
53
+
54
+ Every agent checks once a day whether a newer FediPod is published. When one
55
+ exists, the record page offers **Update**, and `fedipod update` does the same
56
+ from the terminal. `AP_UPDATE_CHECK=0` turns the check off.
57
+
58
+ ## A gateway account
59
+
60
+ Most of what a Fediverse inbox receives is broadcast noise. A
61
+ [gateway](gateway.md) is a shared, always-on door that verifies each delivery,
62
+ drops the junk, and passes the rest to your pod, while your key and data stay
63
+ on your pod. There is a free one at [fedipod.net](https://fedipod.net/).
64
+ Attaching or detaching is a few wizard-guided clicks from your agent, and it
65
+ takes the mail load off your pod's host.
66
+
67
+ ## Clients
68
+
69
+ The bundled client is [Phanpy](https://github.com/cheeaun/phanpy) (MIT, by
70
+ Chee Aun), served by the agent itself, and logging in is one click. If you
71
+ ever enter the instance by hand, use the address on the record's **local
72
+ host** row.
73
+
74
+ - **Other web clients**: drop any static Mastodon client dist into
75
+ `ui/<name>/` and it is served at `/<name>/`; see `ui/README.md`.
76
+ - **Desktop and phone clients** (Tuba, Whalebird, and the like): add
77
+ `https://localhost:8030`, or your agent's port, as a custom instance.
78
+ - **Streaming**: the agent serves the Mastodon streaming API at
79
+ `/api/v1/streaming`, so clients update live instead of polling.
80
+ - **Web push**: notifications reach you while the client is closed.
81
+ - **Scheduled posts** go out at the time you picked.
82
+
83
+ Polls, content warnings, editing, all four visibility levels, direct
84
+ messages, bookmarks, favourites, lists, keyword filters, pinned posts,
85
+ blocking and muting, and custom emojis work as in the browser version.
86
+
87
+ ## Bluesky, and your other Fediverse accounts
88
+
89
+ A Bluesky connection lets the agent drive an existing Bluesky, or other
90
+ ATProto, account alongside your Fediverse identity: public posts are
91
+ cross-posted as a mirror, with a toggle to turn it off; Bluesky replies and
92
+ activity flow into your timeline; you can like, boost and reply to Bluesky
93
+ posts. Direct messages to Bluesky are not supported.
94
+
95
+ An account on Mastodon or any server speaking the Mastodon API can be
96
+ connected from the **Other identities** row of the admin page. Its home
97
+ timeline and notifications join your feed, a post both accounts see appears
98
+ once, and favouriting, boosting and replying act as the account the post came
99
+ through. The token it hands back stays on this machine.
package/groups.md CHANGED
@@ -59,7 +59,7 @@ A followed group's own announced deletion of a post it carried to you is honoure
59
59
  ## Inviting people
60
60
 
61
61
  A group has a page anyone can open, at `ap/profile.html` under its pod's
62
- app container — `<pod>/activitypods-js/ap/profile.html`. It
62
+ app container — `<pod>/fedipod/ap/profile.html`. It
63
63
  carries the group's address and a Follow box that sends a visitor to their
64
64
  own server's follow screen, so it is the link to put where people will find
65
65
  it. Posts the group carries appear in members' timelines as the group
package/gui.md CHANGED
@@ -46,7 +46,7 @@ its own port.
46
46
  ## Sharing an account
47
47
 
48
48
  Each identity has a page anyone can open, at `ap/profile.html` under its pod
49
- — for example `https://your-pod.example/activitypods-js/ap/profile.html`. It
49
+ — for example `https://your-pod.example/fedipod/ap/profile.html`. It
50
50
  shows the name, bio and address, and offers a Follow box: a visitor types
51
51
  their own server and lands on that server's follow screen. Hand out that
52
52
  link, or the `@name@host` address itself, which works in the search box of
@@ -32,8 +32,14 @@ export { attachmentType, extensionFor } from './media.mjs';
32
32
 
33
33
  export class MastoApi {
34
34
  constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
35
- streaming = true, webPush = true, scheduling = true }) {
35
+ mount = '', streaming = true, webPush = true, scheduling = true }) {
36
36
  this.agent = agent;
37
+ // The path this identity's surface answers under, when it shares its origin
38
+ // with others (a suffix pod, e.g. `/aisha`). Empty for a host-root or
39
+ // subdomain pod. Folded into the self-URLs the client is handed —
40
+ // pagination links, the OAuth issuer — so they name the address the client
41
+ // actually reached.
42
+ this.mount = mount;
37
43
  // A server-hosted identity has no CLI of its own, so the advice this gives
38
44
  // when it refuses has to name the route that identity really has.
39
45
  this.embedded = embedded;
@@ -254,7 +254,7 @@ export async function handle(api, ctx) {
254
254
 
255
255
  // A client pages by following these rather than by guessing ids.
256
256
  if (page.length) {
257
- const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
257
+ const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${api.mount || ''}${pathname}`;
258
258
  const link = (params) => {
259
259
  const u = new URL(base);
260
260
  for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
@@ -8,7 +8,7 @@
8
8
  // The verifier is injected so offline tests stub it, and wrapped so the
9
9
  // library (CJS, older jose) can be replaced without touching any caller.
10
10
 
11
- export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null }) {
11
+ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
12
12
  let verify = verifier;
13
13
  const loadVerifier = async () => {
14
14
  if (!verify) {
@@ -31,9 +31,11 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
31
31
  // The URL the client signed its proof over. The Host header already
32
32
  // passed the Authorities firewall, so whichever alias the client used
33
33
  // (localhost, 127.0.0.1, the named origin) is one this agent answers on;
34
- // the scheme is whichever listener the request arrived on.
34
+ // the scheme is whichever listener the request arrived on. `pathname` is
35
+ // relative to this identity's mount, so a suffix pod folds the mount back
36
+ // in — the client signed over the full path it actually requested.
35
37
  const htu = `${scheme ? scheme.replace(/:$/u, '') : req.socket?.encrypted ? 'https' : 'http'
36
- }://${req.headers.host}${pathname}`;
38
+ }://${req.headers.host}${mount}${pathname}`;
37
39
  ({ webid } = await v(
38
40
  req.headers.authorization,
39
41
  req.headers.dpop ? { header: req.headers.dpop, method: req.method, url: htu } : undefined,
package/lib/core/wire.mjs CHANGED
@@ -20,7 +20,10 @@ export { webfingerHost } from '../pod/urls.mjs';
20
20
  // contains; the browser build states `fedipod/` on its own configs. A config
21
21
  // that names a root is always believed — this is only the answer for one that
22
22
  // does not.
23
- export const DEFAULT_ROOT = 'activitypods-js/';
23
+ // Every build writes its data into a `fedipod/` container in the pod. This is
24
+ // the answer for a config that names no root; a config that names one is always
25
+ // believed. (`activitypods-js/` was an earlier name, now abandoned.)
26
+ export const DEFAULT_ROOT = 'fedipod/';
24
27
 
25
28
  // The handle the fediverse sees: a fronted identity's name is the front's.
26
29
  export function publicHandle(config) {
@@ -47,7 +47,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
47
47
  };
48
48
  const podActorId = () => {
49
49
  const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
50
- const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
50
+ const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'fedipod/';
51
51
  return `${base}${root}ap/actor`;
52
52
  };
53
53
  // The reply first, the restart a beat later — same shape as /update.
@@ -76,14 +76,14 @@ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
76
76
  // a fediverse instance must let strangers reach /api and /oauth, so the gate
77
77
  // guards the operator's door (basePath) instead of the whole surface.
78
78
  export function buildAdminSurface({ agent, gate, allowed, log = console.log,
79
- port = null, handle = null, embedded = false, basePath = '/',
79
+ port = null, handle = null, embedded = false, basePath = '/', mount = '',
80
80
  publicOrigin = null, scheme = null,
81
81
  versionOnDisk = () => localVersion(projectRoot) }) {
82
82
  const json = (res, status, obj) => sendJson(res, status, obj, allowed);
83
- const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
83
+ const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount });
84
84
  // The spec's own write API (§6), beside the facade. Its bearer fallback is
85
85
  // the facade's token, so the two surfaces share one notion of the operator.
86
- const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
86
+ const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme, mount }) });
87
87
  const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
88
88
  // Asked per request, not once here: startAdmin runs before connect, so the
89
89
  // kind is not known yet at mount time.
@@ -104,21 +104,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
104
104
  } catch (e) { log(`streaming broadcast: ${e.message}`); }
105
105
  };
106
106
 
107
- // A path as the browser must ask for it: behind the door, prefixed with it.
108
- const atPath = (p_) => (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
107
+ // A path as the browser must ask for it: under this identity's mount (a
108
+ // suffix pod's own path, or nothing) and behind the door, prefixed with it.
109
+ const atPath = (p_) => mount + (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
109
110
 
110
111
  // What every route may reach: the agent and the deployment's facts.
111
112
  const ctx = { agent, log, allowed, embedded, port, handle, publicOrigin, versionOnDisk, isGroup, json, setup: setup_ };
112
113
 
113
114
  const handler = async (req, res) => {
114
115
  const url = new URL(req.url, 'http://localhost');
116
+ // A suffix pod's surface answers under its mount (its own path on a shared
117
+ // host). Strip it once, here, so every route below is matched relative to
118
+ // the mount and a host-root/subdomain pod (empty mount) is unchanged. The
119
+ // full path stays on `url`/`req.url` for self-URLs that fold the mount back
120
+ // in themselves (the pagination base, the DPoP htu).
121
+ let p = url.pathname;
122
+ if (mount && (p === mount || p.startsWith(mount + '/'))) p = p.slice(mount.length) || '/';
115
123
  // Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
116
124
  // routes answer any origin — a browser client is served the way any
117
125
  // instance serves it. CORS headers and the preflight make that work; the
118
126
  // bearer stays the only credential, and the Host check below (which is
119
127
  // what stops DNS rebinding) still runs.
120
- const apiPath = url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/')
121
- || url.pathname === '/.well-known/nodeinfo' || url.pathname === '/nodeinfo/2.0';
128
+ const apiPath = p.startsWith('/api/') || p.startsWith('/oauth/')
129
+ || p === '/.well-known/nodeinfo' || p === '/nodeinfo/2.0';
122
130
  if (apiPath) {
123
131
  res.setHeader('access-control-allow-origin', '*');
124
132
  res.setHeader('access-control-expose-headers', 'Link');
@@ -142,11 +150,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
142
150
  res.end('forbidden\n');
143
151
  return;
144
152
  }
145
- let p = url.pathname;
146
- // Embedded, the operator's door is one path on the pod's origin. Behind it
147
- // is everything that was the admin server; in front of it are the protocol
148
- // routes, which have to answer strangers because that is what makes the pod
149
- // an instance other software can talk to.
153
+ // Embedded, the operator's door is one path on the pod's origin (under the
154
+ // mount, when there is one). Behind it is everything that was the admin
155
+ // server; in front of it are the protocol routes, which have to answer
156
+ // strangers because that is what makes the pod an instance other software
157
+ // can talk to.
150
158
  let atDoor = !embedded;
151
159
  if (embedded && basePath !== '/'
152
160
  && (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
@@ -171,7 +179,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
171
179
  // above still decides who gets this far.
172
180
  if (p === '/.well-known/oauth-authorization-server') {
173
181
  const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
174
- return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
182
+ return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
175
183
  }
176
184
  if (atDoor && gate(req, res)) return;
177
185
  if (p === '/api/v1/streaming/health') {
@@ -180,7 +188,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
180
188
  // NodeInfo on the agent origin — clients probe it at login.
181
189
  if (p === '/.well-known/nodeinfo') {
182
190
  return json(res, 200, nodeinfoPointer(
183
- `${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
191
+ `${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}${mount}/nodeinfo/2.0`));
184
192
  }
185
193
  if (p === '/nodeinfo/2.0') {
186
194
  return json(res, 200, nodeinfoDoc({
@@ -206,8 +214,8 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
206
214
  // Our own pages come before the group check: a group is set up in the
207
215
  // browser like anything else, and it has a record to edit. It still
208
216
  // serves no fediverse client — see the 404 two lines down.
209
- const mount = webMount(p);
210
- if (mount) {
217
+ const wmount = webMount(p);
218
+ if (wmount) {
211
219
  // Without the slash a page's own relative <script src> resolves one
212
220
  // level up and 404s — and that is true at any depth, so ask the
213
221
  // filesystem rather than only special-casing the mount itself.
@@ -217,7 +225,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
217
225
  res.end();
218
226
  return;
219
227
  }
220
- return serveWeb(res, p, mount, allowed);
228
+ return serveWeb(res, p, wmount, allowed);
221
229
  }
222
230
  // The bare URL means "show me what this agent wants from me now".
223
231
  // Keyed on the credential FILE, never on configured(): a healthy
@@ -18,7 +18,7 @@ export async function setup() {
18
18
  if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
19
19
  return runBrowserSetup();
20
20
  }
21
- const root = flag('root');
21
+ const root = flag('root') || 'fedipod/'; // new installs default to the fedipod/ container
22
22
  const kind = has('group') ? 'group' : 'person';
23
23
  const approveJoins = has('group') && has('approve-joins');
24
24
  const summary = flag('summary');
@@ -48,6 +48,14 @@ if (!newAccount && !pod) {
48
48
  }
49
49
  }
50
50
  if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
51
+ if (!newAccount) {
52
+ const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
53
+ const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
54
+ if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
55
+ console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
56
+ process.exit(2);
57
+ }
58
+ }
51
59
 
52
60
  const issuer = flag('issuer') || await ask('Solid identity provider', 'https://solidcommunity.net');
53
61
  // Before the password is asked for, let alone sent. The issuer is where it
@@ -19,6 +19,7 @@ import { hashPassword } from '../client/masto/index.mjs';
19
19
  import { webfingerHost, apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
20
20
  import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
21
21
  import { insecureUrlReason } from '../shared/safefetch.mjs';
22
+ import { resourceExists } from '../pod/root.mjs';
22
23
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
23
24
 
24
25
  const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
@@ -222,6 +223,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
222
223
  gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
223
224
  } = answers;
224
225
  let { pod, root } = answers;
226
+ if (!root) root = 'fedipod/'; // new installs default to the fedipod/ container; a
227
+ // resuming run overwrites this with the credential's own root below.
225
228
  let accountWebId = null; // what createAccountWithPod reported, when it ran
226
229
  // The private half always starts here, beside the credential and the keys —
227
230
  // not on the pod. Every activity you receive would otherwise cost the pod
@@ -263,6 +266,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
263
266
  // reachable, and no silent 401 later on a pod whose profile is empty.
264
267
  const usable = await checkPod(pod);
265
268
  if (!usable.ok) throw new Error(usable.error);
269
+ if (await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor)) {
270
+ throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
271
+ }
266
272
  skip('account', 'using the pod you already have');
267
273
  }
268
274
 
@@ -20,6 +20,7 @@ import { handleDelivery } from './gateway-core.mjs';
20
20
  import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
23
+ import { podBaseOfWebId } from '../pod/urls.mjs';
23
24
 
24
25
  // The one WebFinger document, spelled out here rather than imported from
25
26
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -513,20 +514,31 @@ async function route(request, ctx) {
513
514
  if (!/^https?:\/\/\S+\/$/.test(podBase)) {
514
515
  return j(400, { error: 'podBase must be a URL ending in /' });
515
516
  }
516
- // An identity needs an origin of its own, so only an origin root may opt
517
- // in. Anything deeper would also let one path-pod user claim an ancestor
518
- // of another's pod.
519
- try {
520
- if (new URL(podBase).pathname !== '/') {
521
- return j(403, { error: 'podBase must be a pod origin root, like https://mei.example.org/' });
522
- }
523
- } catch { return j(400, { error: 'podBase is not a URL' }); }
517
+ let podUrl;
518
+ try { podUrl = new URL(podBase); } catch { return j(400, { error: 'podBase is not a URL' }); }
519
+ // A pod may be host-root (its own origin) or on a PATH of a shared host — a
520
+ // suffix pod, which the Server runs at `https://server/aisha/`. What is
521
+ // refused is the gateway's own origin root itself: that is where the
522
+ // gateway lives, not a pod, and admitting it would (with the ownership
523
+ // fallback below) let a co-tenant claim the whole origin.
524
+ if (ctx.frontOrigin) {
525
+ try {
526
+ if (podUrl.origin === new URL(ctx.frontOrigin).origin && podUrl.pathname === '/') {
527
+ return j(403, { error: 'that is this gateway, not a pod — name the pod that holds your data' });
528
+ }
529
+ } catch { /* no usable frontOrigin: the checks below still apply */ }
530
+ }
531
+ if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
532
+ return j(403, { error: 'that is not a pod address' });
533
+ }
524
534
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
525
535
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
526
- // The pod's own server names its owner when it can. Where it does, that is
527
- // the proof; where it does not, the WebID must at least live under the pod.
536
+ // The pod's own server names its owner when it can, and where it does that
537
+ // is the proof. Where it does not, the WebID must live in EXACTLY this pod
538
+ // — its own pod base equal to podBase, not merely starting with it, which on
539
+ // a path server an ancestor of another's pod would.
528
540
  const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
529
- const proven = owners.length ? owners.includes(webid) : webid.startsWith(podBase);
541
+ const proven = owners.length ? owners.includes(webid) : podBaseOfWebId(webid) === podBase;
530
542
  if (!proven) {
531
543
  return j(403, { error: 'the token proves a different pod than the one you listed' });
532
544
  }
package/lib/pod/root.mjs CHANGED
@@ -66,6 +66,17 @@ export async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_L
66
66
  return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? 'path' : null;
67
67
  }
68
68
 
69
+ // Whether a document is there — one unauthenticated GET, 200 or not. The caller
70
+ // names the URL; asked before a second setup, so a pod that already serves an
71
+ // actor at the app's container refuses another rather than growing a duplicate.
72
+ export async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
73
+ try {
74
+ const res = await fetchImpl(url,
75
+ { headers: { accept: 'application/activity+json' }, signal: AbortSignal.timeout(timeoutMs) });
76
+ return !!res && res.status === 200;
77
+ } catch { return false; }
78
+ }
79
+
69
80
  export async function probeAnswers(podUrl, fetchImpl = fetch) {
70
81
  try {
71
82
  const res = await fetchImpl(podUrl, { method: 'HEAD' });
@@ -47,7 +47,8 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
47
47
  */
48
48
  export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
49
49
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
50
- const url = apUrls(base, DEFAULT_ROOT).state + 'door-secret.json';
50
+ // Under the identity's own tree (fedipod/), where the gate reads it back.
51
+ const url = apUrls(base, 'fedipod/').state + 'door-secret.json';
51
52
  const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
52
53
 
53
54
  if (!rotate) {
@@ -136,7 +137,7 @@ function ensureCredential(home, { podBase, webId }) {
136
137
  const rec = {
137
138
  webId,
138
139
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
139
- root: 'activitypods-js/',
140
+ root: 'fedipod/',
140
141
  keysMode: 'pod',
141
142
  };
142
143
  writeJsonAtomic(file, rec, { mode: 0o600 });
@@ -275,7 +276,7 @@ export async function startEmbeddedAgent({
275
276
  pollSeconds = null,
276
277
  autoAcceptFollows = true,
277
278
  gateToken = null,
278
- uiPath = '/fedipod/',
279
+ uiPath = '/fp/',
279
280
  }) {
280
281
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
281
282
  const handle = handleFor(base);
@@ -323,7 +324,7 @@ export async function startEmbeddedAgent({
323
324
  throw new Error(`no pod at ${base} yet — its owner profile is not there`);
324
325
  }
325
326
  log(`no identity on ${base} yet — provisioning @${handle}`);
326
- await agent.bootstrap({ handle, name: handle, kind: 'person' });
327
+ await agent.bootstrap({ handle, name: handle, kind: 'person', root: cred.root });
327
328
  if (autoAcceptFollows) {
328
329
  agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
329
330
  await agent.store.flush();
@@ -342,15 +343,31 @@ export async function startEmbeddedAgent({
342
343
  // speaks, the write API, nodeinfo, and behind the door the admin routes and
343
344
  // the web client. Same code the standalone agent serves, minus the routes
344
345
  // that only mean something to a process of one's own.
346
+ //
347
+ // A pod that lives on a PATH of its host (a suffix pod, e.g.
348
+ // https://server.example/aisha/) shares its origin with the front and with
349
+ // every other suffix pod, so its whole surface answers UNDER that path: the
350
+ // mount is the pod's own pathname, and it is stripped before a route is
351
+ // matched and folded back into every self-URL. A host-root or subdomain pod
352
+ // has an empty mount and everything is exactly as it was.
345
353
  const authorities = new FixedAuthorities(base);
346
354
  agent.authorities = authorities;
355
+ const mount = new URL(base).pathname.replace(/\/+$/u, '');
347
356
  const surface = buildAdminSurface({
348
357
  agent,
349
358
  log,
350
- gate: makeGate(gateToken, { secureCookie: authorities.secure }),
359
+ // The door cookie is named the same for every identity; on a shared origin
360
+ // (suffix pods) it has to be scoped to this identity's own door path so two
361
+ // co-tenants do not overwrite each other's. A host-root/subdomain pod keeps
362
+ // the whole-origin cookie it always had.
363
+ gate: makeGate(gateToken, {
364
+ secureCookie: authorities.secure,
365
+ cookiePath: mount ? mount + uiPath : '/',
366
+ }),
351
367
  allowed: authorities,
352
368
  embedded: true,
353
369
  basePath: uiPath,
370
+ mount,
354
371
  publicOrigin: base,
355
372
  scheme: new URL(base).protocol,
356
373
  });
@@ -399,7 +416,7 @@ export async function startEmbeddedAgent({
399
416
  // podHome and actorUrl are the identity's own locations on the pod. They are
400
417
  // returned rather than rebuilt by the caller so the root name lives here.
401
418
  return {
402
- agent, handle, home, surface, host: authorities.host,
419
+ agent, handle, home, surface, host: authorities.host, mount,
403
420
  podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
404
421
  };
405
422
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "cli.md",
28
28
  "groups.md",
29
29
  "architecture.svg",
30
- "installed-agent.md",
30
+ "device-agent.md",
31
31
  "architecture.md",
32
32
  "gateway.md",
33
33
  "browser.svg"
package/run-agent.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // run-agent.mjs — fedipod: a standalone single-actor ActivityPub
2
2
  // agent. The remote pod is a RELAY: it serves the public wire face
3
- // (/activitypods-js/ap/) and buffers inbound mail in a public-append inbox
3
+ // (/fedipod/ap/) and buffers inbound mail in a public-append inbox
4
4
  // while this process is off, and it keeps one private document, the lease,
5
5
  // because a lock only one machine can reach coordinates nothing.
6
6
  //
package/vendor/gate.cjs CHANGED
@@ -85,7 +85,7 @@ function cookieValue(header, name) {
85
85
  // AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
86
86
  // `token` may be a function, resolved per request: an identity's secret can
87
87
  // rotate while the server runs, and the very next request sees the new one.
88
- function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false } = {}) {
88
+ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/' } = {}) {
89
89
  const tokenNow = () => (typeof token === 'function' ? token() : token);
90
90
  // gate(req, res) → true when the gate handled the response (caller stops).
91
91
  function gate(req, res) {
@@ -102,7 +102,10 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
102
102
  url.searchParams.delete(COOKIE);
103
103
  url.searchParams.delete('dk-bless');
104
104
  res.writeHead(302, {
105
- 'set-cookie': `${COOKIE}=${t}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`
105
+ // Path scopes the cookie to this identity's own door: on a shared
106
+ // origin (suffix pods) two co-tenants must not clobber each other's,
107
+ // and a whole-origin cookie would. Defaults to '/'.
108
+ 'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=31536000`
106
109
  + (secureCookie ? '; Secure' : ''),
107
110
  'location': url.pathname + url.search,
108
111
  });
package/web/app/agent.mjs CHANGED
@@ -189,7 +189,7 @@ export class BrowserAgent {
189
189
  // builds its own urls from `config.root` (publisher.mjs), and a config
190
190
  // without one falls to the Node default — so an account set up elsewhere
191
191
  // and signed into here would keep its state under `fedipod/` while every
192
- // document it published landed under `activitypods-js/`. One root, decided
192
+ // document it published landed under a different root. One root, decided
193
193
  // once, carried by the config everything downstream reads.
194
194
  this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
195
195
  config = this.store.getConfig();