fedipod-server 0.16.0 → 0.18.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 +37 -15
- package/dist/claims.d.ts +10 -11
- package/dist/claims.js +19 -17
- package/dist/directory.d.ts +11 -3
- package/dist/directory.js +28 -14
- package/dist/handler.d.ts +30 -7
- package/dist/handler.js +133 -42
- package/dist/handler.jsonld +14 -6
- package/dist/streaming-handler.js +7 -2
- package/lib/client/c2s.mjs +95 -61
- package/lib/client/masto/index.mjs +7 -1
- package/lib/client/masto/timelines.mjs +1 -1
- package/lib/client/oidc-auth.mjs +5 -3
- package/lib/core/contexts/anno.json +126 -0
- package/lib/core/contexts/index.mjs +4 -0
- package/lib/core/contexts/map.json +2 -1
- package/lib/core/intake/index.mjs +32 -5
- package/lib/core/publisher/index.mjs +15 -1
- package/lib/core/publisher/notes.mjs +84 -2
- package/lib/core/publisher/questions.mjs +1 -0
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +4 -0
- package/lib/core/wire.mjs +20 -13
- package/lib/device/admin/routes/gateway.mjs +1 -1
- package/lib/device/admin/surface.mjs +25 -17
- package/lib/device/cli/commands/setup.mjs +9 -1
- package/lib/device/setup.mjs +6 -0
- package/lib/gateway/front-core.mjs +66 -12
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/root.mjs +11 -0
- package/lib/pod/transport.mjs +9 -3
- package/lib/server/embed.mjs +23 -6
- package/package.json +2 -1
- package/run-agent.mjs +10 -1
- package/vendor/gate.cjs +5 -2
- package/web/admin/index.html +2 -0
- package/web/admin/upkeep.js +9 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +11 -1
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +89 -37
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +1326 -662
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/signup.mjs +3 -1
- package/web/app/site/admin/index.html +2 -0
- package/web/app/site/admin/upkeep.js +9 -1
- package/web/app/site/boot.js +89 -37
- package/web/app/site/index.html +16 -6
- package/web/app/site/sw.js +1326 -662
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
|
@@ -76,6 +76,15 @@ export class Publisher {
|
|
|
76
76
|
// republish button. Without it, an actor lost from the pod would match the
|
|
77
77
|
// digest, be skipped, and leave the agent reporting success while nobody can
|
|
78
78
|
// resolve it.
|
|
79
|
+
// The Gateway's outbox door for this account, when a Gateway is attached:
|
|
80
|
+
// beside its inbox door, or under the fronted actor.
|
|
81
|
+
gatewayOutbox() {
|
|
82
|
+
const gw = this.config.gateway;
|
|
83
|
+
if (!(gw && gw.url && gw.mode && gw.mode !== 'off')) return null;
|
|
84
|
+
if (gw.frontActor) return String(gw.frontActor).replace(/ap\/actor\/?$/u, 'ap/outbox');
|
|
85
|
+
return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
async publishProfile({ force = false } = {}) {
|
|
80
89
|
const { urls } = this;
|
|
81
90
|
const host = new URL(urls.base).host;
|
|
@@ -105,7 +114,8 @@ export class Publisher {
|
|
|
105
114
|
inbox: gwActive ? gw.url : null,
|
|
106
115
|
// The agent's own outbox endpoint, where it is reachable: a client
|
|
107
116
|
// following the actor must arrive somewhere that will take a write.
|
|
108
|
-
|
|
117
|
+
// Otherwise the Gateway's outbox door, when one is attached.
|
|
118
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
|
|
109
119
|
// How a client-to-server client finds the way in with nothing configured
|
|
110
120
|
// by hand. Advertised only where the surface is publicly reachable.
|
|
111
121
|
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
@@ -163,6 +173,8 @@ export class Publisher {
|
|
|
163
173
|
actorUrl: urls.actor,
|
|
164
174
|
accountName: `@${this.config.handle}@${host}`,
|
|
165
175
|
kind: this.config.kind,
|
|
176
|
+
// Where a Solid client posts: dokieli reads `as:outbox` off the WebID.
|
|
177
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
|
|
166
178
|
});
|
|
167
179
|
if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
|
|
168
180
|
} catch (e) {
|
|
@@ -400,6 +412,7 @@ export class Publisher {
|
|
|
400
412
|
reconcileFollowers(...a) { return restore.reconcileFollowers(this, ...a); }
|
|
401
413
|
reconcileOutbox(...a) { return restore.reconcileOutbox(this, ...a); }
|
|
402
414
|
rebuildStatuses(...a) { return restore.rebuildStatuses(this, ...a); }
|
|
415
|
+
healStatuses(...a) { return restore.healStatuses(this, ...a); }
|
|
403
416
|
|
|
404
417
|
// notes.mjs
|
|
405
418
|
ensureMediaContainer(...a) { return notes.ensureMediaContainer(this, ...a); }
|
|
@@ -408,6 +421,7 @@ export class Publisher {
|
|
|
408
421
|
privateReady(...a) { return notes.privateReady(this, ...a); }
|
|
409
422
|
_mentionsFor(...a) { return notes.mentionsFor(this, ...a); }
|
|
410
423
|
publishNote(...a) { return notes.publishNote(this, ...a); }
|
|
424
|
+
publishObject(...a) { return notes.publishObject(this, ...a); }
|
|
411
425
|
updateNote(...a) { return notes.updateNote(this, ...a); }
|
|
412
426
|
|
|
413
427
|
// questions.mjs
|
|
@@ -99,7 +99,28 @@ export function assertDirectAddressed(content, mentions) {
|
|
|
99
99
|
throw e;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
// A client may name the document it is creating (the Slug of a client-to-server
|
|
103
|
+
// POST). Only a plain name is taken, and only when nothing is there already;
|
|
104
|
+
// otherwise the agent mints one as it always has.
|
|
105
|
+
const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
106
|
+
export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
|
|
107
|
+
async function slugFor(publisher, container, wanted, published) {
|
|
108
|
+
const name = safeSlug(wanted);
|
|
109
|
+
if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
|
|
110
|
+
return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// What the author's own timeline shows for an object: its content, else its
|
|
114
|
+
// text under one of the names other vocabularies use, else a link to it.
|
|
115
|
+
export function rowContent(obj) {
|
|
116
|
+
if (typeof obj?.content === 'string' && obj.content.trim()) return wire.sanitizeHtml(obj.content);
|
|
117
|
+
const plain = [obj?.bodyValue, obj?.name, obj?.summary].find((v) => typeof v === 'string' && v.trim());
|
|
118
|
+
if (plain) return wire.contentHtml(plain);
|
|
119
|
+
const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
120
|
+
return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, slug: wanted = null } = {}) {
|
|
103
124
|
const { urls } = publisher;
|
|
104
125
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
105
126
|
if (priv) {
|
|
@@ -107,7 +128,7 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
107
128
|
if (ready !== true) throw new Error(ready);
|
|
108
129
|
}
|
|
109
130
|
const published = new Date().toISOString();
|
|
110
|
-
const slug =
|
|
131
|
+
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
111
132
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
112
133
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
113
134
|
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
@@ -127,6 +148,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
127
148
|
...(attachments?.length ? { attachments } : {}),
|
|
128
149
|
...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
|
|
129
150
|
});
|
|
151
|
+
// The row is what the author's own timeline reads. Land it before answering
|
|
152
|
+
// — the same PUT the debounce would send in 300 ms — so a worker killed or a
|
|
153
|
+
// write refused after the answer cannot leave a post that stands on the pod
|
|
154
|
+
// and reached followers but never shows to its author.
|
|
155
|
+
if (await publisher.store.commit?.() === false) publisher.log(`post published but its timeline row was refused: ${note.id}`);
|
|
130
156
|
|
|
131
157
|
const create = wire.createActivity(note, urls);
|
|
132
158
|
// Published as its own document: a group that carries this post wraps the
|
|
@@ -164,6 +190,62 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
164
190
|
// edit's own stamp. The pod documents are overwritten in place — the Create
|
|
165
191
|
// too, so a group's Announce resolves to the edited text — and an Update
|
|
166
192
|
// goes everywhere the Create went.
|
|
193
|
+
// Any object as a post: what a client-to-server Create carries when it is not
|
|
194
|
+
// a Note or a Question — a Web Annotation, say. Stored as sent, with its own
|
|
195
|
+
// context, under this actor; the Create around it is what followers receive.
|
|
196
|
+
// Servers that know the type show it; the rest ignore it, which is the
|
|
197
|
+
// expected outcome.
|
|
198
|
+
//
|
|
199
|
+
// An object already living on this pod (an id under it) is not copied: the
|
|
200
|
+
// Create names it where it is. Anything else is written into the notes
|
|
201
|
+
// container under the client's slug when it is free, else a minted one.
|
|
202
|
+
export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null } = {}) {
|
|
203
|
+
const { urls } = publisher;
|
|
204
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
205
|
+
if (priv) {
|
|
206
|
+
const ready = await publisher.privateReady();
|
|
207
|
+
if (ready !== true) throw new Error(ready);
|
|
208
|
+
}
|
|
209
|
+
const published = new Date().toISOString();
|
|
210
|
+
const container = priv ? urls.privateNotes : urls.notes;
|
|
211
|
+
const pod = publisher.config?.remotePod || urls.base;
|
|
212
|
+
const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
|
|
213
|
+
? object.id : null;
|
|
214
|
+
const name = await slugFor(publisher, container, wanted, published);
|
|
215
|
+
const addressed = wire.addressing(urls, visibility);
|
|
216
|
+
let doc = null;
|
|
217
|
+
const id = ownId || container + name;
|
|
218
|
+
if (!ownId) {
|
|
219
|
+
doc = { ...object, id, attributedTo: urls.actor, published: object.published || published, to: addressed.to, cc: addressed.cc };
|
|
220
|
+
if (!doc['@context']) doc['@context'] = wire.AS_CTX;
|
|
221
|
+
if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
|
|
222
|
+
await podNotes.write(publisher.remote, id, doc);
|
|
223
|
+
}
|
|
224
|
+
if (!priv) await publisher.recordOutbox(id);
|
|
225
|
+
const row = doc || object;
|
|
226
|
+
publisher.store.addStatus({
|
|
227
|
+
noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
|
|
228
|
+
kind: 'post', slug: name, visibility,
|
|
229
|
+
});
|
|
230
|
+
if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
|
|
231
|
+
|
|
232
|
+
// The Create sits beside the object; for an object living elsewhere on the
|
|
233
|
+
// pod it sits in the notes container, whose ACL is public Read.
|
|
234
|
+
const createId = doc ? wire.createActivityId(id) : wire.createActivityId(container + name);
|
|
235
|
+
const create = {
|
|
236
|
+
'@context': wire.AS_CTX, id: createId, type: 'Create', actor: urls.actor,
|
|
237
|
+
published: row.published || published, to: addressed.to, cc: addressed.cc,
|
|
238
|
+
object: doc || id,
|
|
239
|
+
};
|
|
240
|
+
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
241
|
+
const contacts = publisher.store.getContacts();
|
|
242
|
+
const inboxes = visibility === 'direct' ? []
|
|
243
|
+
: [...new Set(contacts.followers.map((f) => f.sharedInbox || f.inbox).filter(Boolean))];
|
|
244
|
+
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
245
|
+
publisher.log(`${row.type || 'object'} published: ${id} → ${inboxes.length} inbox(es)`);
|
|
246
|
+
return { id, createId, copied: !ownId };
|
|
247
|
+
}
|
|
248
|
+
|
|
167
249
|
export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
|
|
168
250
|
const { urls } = publisher;
|
|
169
251
|
const updated = new Date().toISOString();
|
|
@@ -82,6 +82,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
82
82
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
83
83
|
...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
|
|
84
84
|
});
|
|
85
|
+
if (await publisher.store.commit?.() === false) publisher.log(`poll published but its timeline row was refused: ${question.id}`);
|
|
85
86
|
|
|
86
87
|
const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
|
|
87
88
|
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as wire from '../wire.mjs';
|
|
6
6
|
import * as podNotes from '../../pod/notes.mjs';
|
|
7
7
|
import { readLenient } from '../as2.mjs';
|
|
8
|
+
import { rowContent } from './notes.mjs';
|
|
8
9
|
|
|
9
10
|
const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
10
11
|
const REBUILD_MAX_PER_RUN = 200;
|
|
@@ -151,13 +152,14 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
|
151
152
|
if (have.has(id) || removed.has(id)) continue;
|
|
152
153
|
budget--;
|
|
153
154
|
const note = await podNotes.read(publisher.remote, id).catch(() => null);
|
|
154
|
-
|
|
155
|
+
// Any object of ours: a Note, a poll, or whatever a client posted.
|
|
156
|
+
if (!note?.type || note.id !== id || note.attributedTo !== urls.actor) continue;
|
|
155
157
|
const attachments = wire.attachmentsOf(note);
|
|
156
158
|
const mentions = (Array.isArray(note.tag) ? note.tag : [])
|
|
157
159
|
.filter(t => t?.type === 'Mention' && t.href)
|
|
158
160
|
.map(t => ({ href: t.href, name: t.name }));
|
|
159
161
|
recovered.push({
|
|
160
|
-
noteId: note.id, actor: urls.actor, content: note
|
|
162
|
+
noteId: note.id, actor: urls.actor, content: rowContent(note),
|
|
161
163
|
published: note.published || null,
|
|
162
164
|
...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
|
|
163
165
|
kind: 'post', slug: note.id.slice(urls.notes.length),
|
|
@@ -197,3 +199,26 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
|
197
199
|
dropped: Math.max(0, merged.length - kept.length),
|
|
198
200
|
};
|
|
199
201
|
}
|
|
202
|
+
|
|
203
|
+
// Own posts the outbox records and the statuses index does not.
|
|
204
|
+
//
|
|
205
|
+
// The index row is the last thing a post writes, and it goes by a debounced
|
|
206
|
+
// PUT: a worker killed or a write refused after the post was answered leaves a
|
|
207
|
+
// post that stands on the pod and reached followers but never shows on its
|
|
208
|
+
// author's own timeline. Run when an agent becomes active. Cheap when nothing
|
|
209
|
+
// is wrong — two cached documents compared, no request — and the capped,
|
|
210
|
+
// merge-only rebuild when something is.
|
|
211
|
+
export async function healStatuses(publisher) {
|
|
212
|
+
const { urls, store } = publisher;
|
|
213
|
+
const own = store.read('outbox.json', [])
|
|
214
|
+
.map(i => (typeof i === 'string' ? i : null))
|
|
215
|
+
.filter(id => id && id.startsWith(urls.notes));
|
|
216
|
+
if (!own.length) return { missing: 0, recovered: 0 };
|
|
217
|
+
const have = new Set(store.getStatuses().map(s => s.noteId));
|
|
218
|
+
const removed = new Set(store.read('outbox-removed.json', []).map(r => r.id));
|
|
219
|
+
const missing = own.filter(id => !have.has(id) && !removed.has(id));
|
|
220
|
+
if (!missing.length) return { missing: 0, recovered: 0 };
|
|
221
|
+
const r = await rebuildStatuses(publisher);
|
|
222
|
+
publisher.log(`timeline index healed: ${missing.length} own post(s) were missing, ${r.recovered} recovered`);
|
|
223
|
+
return { missing: missing.length, recovered: r.recovered };
|
|
224
|
+
}
|
package/lib/core/social.mjs
CHANGED
|
@@ -204,6 +204,7 @@ export async function reblog(agent, s) {
|
|
|
204
204
|
// missing entry, a failed status write would let a retry announce twice.
|
|
205
205
|
const updated = agent.store.updateStatus(s.noteId, { reblogged: true, announceActivity: act });
|
|
206
206
|
await agent.publisher.recordOutbox(act);
|
|
207
|
+
if (await agent.store.commit?.() === false) agent.log(`boost sent but its timeline row was refused: ${s.noteId}`);
|
|
207
208
|
return updated;
|
|
208
209
|
}
|
|
209
210
|
|
package/lib/core/store.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export function dropFollower(contacts, actor, why) {
|
|
|
74
74
|
|
|
75
75
|
export class PodStore {
|
|
76
76
|
constructor({ storage = null, log = console.log } = {}) {
|
|
77
|
+
this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
|
|
77
78
|
this.storage = storage;
|
|
78
79
|
this.log = log;
|
|
79
80
|
this.cache = new Map(); // name → parsed value
|
|
@@ -146,6 +147,9 @@ export class PodStore {
|
|
|
146
147
|
try { this.cache.set(name, JSON.parse(r.body)); }
|
|
147
148
|
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
148
149
|
}
|
|
150
|
+
// Kept for /status: a document skipped here is a timeline or a contact
|
|
151
|
+
// list quietly missing, and the owner should be able to see that.
|
|
152
|
+
this.lastSkipped = skipped;
|
|
149
153
|
if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
|
|
150
154
|
this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
|
|
151
155
|
}
|
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
|
-
|
|
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) {
|
|
@@ -602,29 +605,33 @@ export function contentHtml(text, mentions = []) {
|
|
|
602
605
|
return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
|
|
603
606
|
}
|
|
604
607
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
// direct post) and will not notify anyone it does not find there or in the
|
|
613
|
-
// tags.
|
|
614
|
-
const addressing = {
|
|
608
|
+
// Mastodon's four shapes. Public and unlisted are world-readable; private
|
|
609
|
+
// (followers-only) and direct carry no Public address at all and belong in
|
|
610
|
+
// the owner-only container. Mastodon addresses mentions in cc (to, for a
|
|
611
|
+
// direct post) and will not notify anyone it does not find there or in the
|
|
612
|
+
// tags.
|
|
613
|
+
export function addressing(urls, visibility, who = []) {
|
|
614
|
+
return {
|
|
615
615
|
public: { to: [PUBLIC], cc: [urls.followers, ...who] },
|
|
616
616
|
unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
|
|
617
617
|
private: { to: [urls.followers], cc: who },
|
|
618
618
|
direct: { to: who, cc: [] },
|
|
619
619
|
}[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
|
|
623
|
+
visibility = 'public', summary = null, updated = null, container = null }) {
|
|
624
|
+
const id = (container || urls.notes) + slug;
|
|
625
|
+
const who = mentions.map(m => m.actor);
|
|
626
|
+
const addressed = addressing(urls, visibility, who);
|
|
620
627
|
const note = {
|
|
621
628
|
'@context': AS_CTX,
|
|
622
629
|
id, type: 'Note',
|
|
623
630
|
attributedTo: urls.actor,
|
|
624
631
|
content: contentHtml(content, mentions),
|
|
625
632
|
published,
|
|
626
|
-
to:
|
|
627
|
-
cc:
|
|
633
|
+
to: addressed.to,
|
|
634
|
+
cc: addressed.cc,
|
|
628
635
|
replies: repliesId(id),
|
|
629
636
|
};
|
|
630
637
|
if (summary) note.summary = summary; // the content warning
|
|
@@ -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}/`) : '
|
|
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:
|
|
108
|
-
|
|
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 =
|
|
121
|
-
||
|
|
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
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
|
210
|
-
if (
|
|
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,
|
|
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
|
package/lib/device/setup.mjs
CHANGED
|
@@ -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
|
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
// HTTPS box is another. UN-DEPLOYED — nothing in FediPod runs it.
|
|
17
17
|
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
|
-
import { handleDelivery } from './gateway-core.mjs';
|
|
19
|
+
import { handleDelivery, handleOwnerPost } 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
|
-
|
|
517
|
-
|
|
518
|
-
// of
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
527
|
-
// the proof
|
|
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
|
|
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
|
}
|
|
@@ -607,6 +619,48 @@ async function route(request, ctx) {
|
|
|
607
619
|
return { status, headers: {}, body: '' };
|
|
608
620
|
}
|
|
609
621
|
|
|
622
|
+
// Outbox: the owner's own post, from any client that speaks ActivityPub
|
|
623
|
+
// client-to-server (dokieli, say). Cross-origin by nature — such a client is
|
|
624
|
+
// a page on another site — so this route answers a preflight and carries
|
|
625
|
+
// CORS headers; the owner's pod token is the credential, so any origin may
|
|
626
|
+
// ask. GET stays a read: the pod's document for a fronted account, sent on
|
|
627
|
+
// to the pod for a door account whose documents live at their own address.
|
|
628
|
+
if (up.rest === 'ap/outbox') {
|
|
629
|
+
const cors = {
|
|
630
|
+
'access-control-allow-origin': '*',
|
|
631
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
632
|
+
'access-control-allow-headers': 'Authorization, DPoP, Content-Type, Slug, Link',
|
|
633
|
+
'access-control-expose-headers': 'Location, Accept-Post',
|
|
634
|
+
'access-control-max-age': '86400',
|
|
635
|
+
};
|
|
636
|
+
if (request.method === 'OPTIONS') {
|
|
637
|
+
// Accept-Post names JSON only: a client that reads it (dokieli) sends
|
|
638
|
+
// JSON-LD when HTML is not offered.
|
|
639
|
+
// body null, not '': a Response refuses any body on a 204, and the
|
|
640
|
+
// function adapter hands `body` straight to one.
|
|
641
|
+
return { status: 204, headers: { ...cors, allow: 'GET, POST, OPTIONS',
|
|
642
|
+
'accept-post': 'application/ld+json, application/activity+json' }, body: null };
|
|
643
|
+
}
|
|
644
|
+
if (request.method === 'POST') {
|
|
645
|
+
const json = (status, obj, extra = {}) => ({ status,
|
|
646
|
+
headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
|
|
647
|
+
body: JSON.stringify(obj) });
|
|
648
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
649
|
+
if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
|
|
650
|
+
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
651
|
+
if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
652
|
+
const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
|
|
653
|
+
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
|
|
654
|
+
console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
|
|
655
|
+
if (status !== 202) return json(status, { error: reason });
|
|
656
|
+
return json(202, { accepted: true, ...(location ? { object: location } : {}),
|
|
657
|
+
note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
|
|
658
|
+
}
|
|
659
|
+
if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
660
|
+
return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
610
664
|
// Everything else is a public GET, served by reading the user's pod and
|
|
611
665
|
// rewriting pod ids to the front. The actor also gets its handle and inbox
|
|
612
666
|
// fixed to the front so a consumer cross-checks it consistently.
|
|
@@ -110,4 +110,44 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
110
110
|
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// The outbox door: the owner's own post, taken on their behalf.
|
|
114
|
+
//
|
|
115
|
+
// A client such as dokieli POSTs an activity — or a bare object — to the
|
|
116
|
+
// outbox address the actor advertises. The door holds no key and mints nothing:
|
|
117
|
+
// it checks that the token proves the account's owner (the caller has already
|
|
118
|
+
// done that and hands in `owner`), writes the bytes into the pod inbox exactly
|
|
119
|
+
// as verified mail is written, and stamps them with a receipt whose method is
|
|
120
|
+
// `c2s` and whose actor is this account. The drain hands such an item to the
|
|
121
|
+
// client-to-server dispatcher, which publishes and delivers it — so the post
|
|
122
|
+
// goes out when the agent next runs, the same way inbound mail is read.
|
|
123
|
+
//
|
|
124
|
+
// `slug` is the name the client asked for its new document. It rides in the
|
|
125
|
+
// receipt so the dispatcher can use it, and it is what lets the door answer a
|
|
126
|
+
// Location before anything exists: the object will live at notesPrefix+slug
|
|
127
|
+
// unless that name is taken, in which case the agent mints another.
|
|
128
|
+
export const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
129
|
+
export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
|
|
130
|
+
|
|
131
|
+
export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
132
|
+
if (!ident.hmacSecret) return { status: 409, reason: 'this account has no door secret — attach it again' };
|
|
133
|
+
let raw;
|
|
134
|
+
try { raw = await request.text(); } catch { return { status: 400, reason: 'unreadable body' }; }
|
|
135
|
+
if (Buffer.byteLength(raw) > maxBytes) return { status: 413, reason: 'too large' };
|
|
136
|
+
let doc;
|
|
137
|
+
try { doc = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
|
|
138
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !doc.type) {
|
|
139
|
+
return { status: 400, reason: 'a typed ActivityStreams object is required' };
|
|
140
|
+
}
|
|
141
|
+
const slug = safeSlug(request.headers.get('slug'));
|
|
142
|
+
const receipt = signReceipt({
|
|
143
|
+
v: 1, verified: true, method: 'c2s', keyId: ownerWebId || null, actor: ident.actorUrl,
|
|
144
|
+
checks: ['owner-token'], reason: 'owner', gateway: ident.gatewayWebId, ...(slug ? { slug } : {}),
|
|
145
|
+
}, ident.hmacSecret);
|
|
146
|
+
const hash = sha256hex(raw);
|
|
147
|
+
const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
|
|
148
|
+
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
149
|
+
await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
150
|
+
return { status: 202, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
|
|
151
|
+
}
|
|
152
|
+
|
|
113
153
|
export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
|