@oxy.so/federation 2.4.2 → 2.5.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.
@@ -22,6 +22,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = void 0;
23
23
  exports.isApActorType = isApActorType;
24
24
  exports.localActorTypeForAccountKind = localActorTypeForAccountKind;
25
+ exports.accountKindForActor = accountKindForActor;
25
26
  exports.normalizeAlsoKnownAs = normalizeAlsoKnownAs;
26
27
  exports.createLocalActorBuilder = createLocalActorBuilder;
27
28
  const contracts_1 = require("@oxy.so/contracts");
@@ -46,7 +47,10 @@ function isApActorType(value) {
46
47
  return typeof value === 'string' && exports.AP_ACTOR_TYPES.includes(value);
47
48
  }
48
49
  /**
49
- * Oxy account kind → the AS2 actor type the fediverse is told about it.
50
+ * Oxy account kind → the AS2 actor type the fediverse is told about it — the
51
+ * ONE table both directions read: what Oxy announces for its own accounts here,
52
+ * and, through {@link accountKindForActor}, what it records a remote actor as.
53
+ * Changing how a kind federates is an edit to this table and nowhere else.
50
54
  *
51
55
  * `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
52
56
  * kind added to `@oxy.so/contracts` fails THIS build rather than silently
@@ -78,6 +82,8 @@ function isApActorType(value) {
78
82
  * accepted by all four implementations' whitelists and compared in none of
79
83
  * them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
80
84
  * Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
85
+ * - **`community` → `Group`.** A forum its members post into and that
86
+ * re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
81
87
  *
82
88
  * What this does NOT do: it does not stop a remote instance offering a reply box
83
89
  * under a channel's post. NO actor type gates that in any of the four — Mastodon
@@ -92,6 +98,7 @@ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
92
98
  project: 'Organization',
93
99
  bot: 'Service',
94
100
  channel: 'Organization',
101
+ community: 'Group',
95
102
  };
96
103
  /**
97
104
  * The AS2 actor type for an Oxy account kind, defaulting to `Person`.
@@ -108,6 +115,49 @@ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
108
115
  function localActorTypeForAccountKind(kind) {
109
116
  return (0, contracts_1.isAccountKind)(kind) ? exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
110
117
  }
118
+ /**
119
+ * The inverse of {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}, derived from it. Three
120
+ * kinds share `Organization`, so a type reads back as the FIRST kind the table
121
+ * lists for it (`Organization` → `organization`); nothing on the wire tells a
122
+ * remote project from a remote organization.
123
+ */
124
+ const ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE = (() => {
125
+ const inverse = new Map();
126
+ for (const [kind, type] of Object.entries(exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
127
+ if (!inverse.has(type))
128
+ inverse.set(type, kind);
129
+ }
130
+ return inverse;
131
+ })();
132
+ /**
133
+ * AS2 actor types Oxy never announces but receives, and the announced type each
134
+ * is read as. `Application` is a non-human software actor — what `Service`
135
+ * already says, and half of Mastodon's own `bot?`.
136
+ */
137
+ const INBOUND_ACTOR_TYPE_ALIASES = new Map([
138
+ ['Application', 'Service'],
139
+ ]);
140
+ /**
141
+ * The kind a `Group` that only its moderators may post into is recorded as.
142
+ * Such a group forwards nobody: it PUBLISHES (a PeerTube video channel, a
143
+ * WordPress multi-author blog), which is what an Oxy channel is. The one
144
+ * inbound signal the table above has no column for.
145
+ */
146
+ const MODERATED_GROUP_KIND = 'channel';
147
+ /**
148
+ * The Oxy account kind a REMOTE actor is recorded as, read from the same table
149
+ * the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
150
+ * the table does not know (absent, misspelt, an extension type) is `personal`,
151
+ * the same default {@link localActorTypeForAccountKind} announces.
152
+ */
153
+ function accountKindForActor(actor) {
154
+ if (typeof actor.type !== 'string')
155
+ return 'personal';
156
+ const type = INBOUND_ACTOR_TYPE_ALIASES.get(actor.type) ?? actor.type;
157
+ if (type === exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community && actor.postingRestrictedToMods === true)
158
+ return MODERATED_GROUP_KIND;
159
+ return ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE.get(type) ?? 'personal';
160
+ }
111
161
  /** Map common image extensions to a MIME type for an actor image `mediaType`. */
112
162
  const IMAGE_MEDIA_TYPE_BY_EXT = {
113
163
  png: 'image/png',
package/dist/cjs/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * knowledge of any app's post shape.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
25
+ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
26
26
  /**
27
27
  * HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
28
28
  * ActivityPub federation shares. Private-key custody is injected; the key never
@@ -95,6 +95,7 @@ var actorObject_1 = require("./actorObject");
95
95
  Object.defineProperty(exports, "createLocalActorBuilder", { enumerable: true, get: function () { return actorObject_1.createLocalActorBuilder; } });
96
96
  Object.defineProperty(exports, "normalizeAlsoKnownAs", { enumerable: true, get: function () { return actorObject_1.normalizeAlsoKnownAs; } });
97
97
  Object.defineProperty(exports, "localActorTypeForAccountKind", { enumerable: true, get: function () { return actorObject_1.localActorTypeForAccountKind; } });
98
+ Object.defineProperty(exports, "accountKindForActor", { enumerable: true, get: function () { return actorObject_1.accountKindForActor; } });
98
99
  Object.defineProperty(exports, "isApActorType", { enumerable: true, get: function () { return actorObject_1.isApActorType; } });
99
100
  Object.defineProperty(exports, "AP_ACTOR_TYPES", { enumerable: true, get: function () { return actorObject_1.AP_ACTOR_TYPES; } });
100
101
  Object.defineProperty(exports, "LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND", { enumerable: true, get: function () { return actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND; } });
@@ -39,7 +39,10 @@ export function isApActorType(value) {
39
39
  return typeof value === 'string' && AP_ACTOR_TYPES.includes(value);
40
40
  }
41
41
  /**
42
- * Oxy account kind → the AS2 actor type the fediverse is told about it.
42
+ * Oxy account kind → the AS2 actor type the fediverse is told about it — the
43
+ * ONE table both directions read: what Oxy announces for its own accounts here,
44
+ * and, through {@link accountKindForActor}, what it records a remote actor as.
45
+ * Changing how a kind federates is an edit to this table and nowhere else.
43
46
  *
44
47
  * `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
45
48
  * kind added to `@oxy.so/contracts` fails THIS build rather than silently
@@ -71,6 +74,8 @@ export function isApActorType(value) {
71
74
  * accepted by all four implementations' whitelists and compared in none of
72
75
  * them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
73
76
  * Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
77
+ * - **`community` → `Group`.** A forum its members post into and that
78
+ * re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
74
79
  *
75
80
  * What this does NOT do: it does not stop a remote instance offering a reply box
76
81
  * under a channel's post. NO actor type gates that in any of the four — Mastodon
@@ -85,6 +90,7 @@ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
85
90
  project: 'Organization',
86
91
  bot: 'Service',
87
92
  channel: 'Organization',
93
+ community: 'Group',
88
94
  };
89
95
  /**
90
96
  * The AS2 actor type for an Oxy account kind, defaulting to `Person`.
@@ -101,6 +107,49 @@ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
101
107
  export function localActorTypeForAccountKind(kind) {
102
108
  return isAccountKind(kind) ? LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
103
109
  }
110
+ /**
111
+ * The inverse of {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}, derived from it. Three
112
+ * kinds share `Organization`, so a type reads back as the FIRST kind the table
113
+ * lists for it (`Organization` → `organization`); nothing on the wire tells a
114
+ * remote project from a remote organization.
115
+ */
116
+ const ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE = (() => {
117
+ const inverse = new Map();
118
+ for (const [kind, type] of Object.entries(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
119
+ if (!inverse.has(type))
120
+ inverse.set(type, kind);
121
+ }
122
+ return inverse;
123
+ })();
124
+ /**
125
+ * AS2 actor types Oxy never announces but receives, and the announced type each
126
+ * is read as. `Application` is a non-human software actor — what `Service`
127
+ * already says, and half of Mastodon's own `bot?`.
128
+ */
129
+ const INBOUND_ACTOR_TYPE_ALIASES = new Map([
130
+ ['Application', 'Service'],
131
+ ]);
132
+ /**
133
+ * The kind a `Group` that only its moderators may post into is recorded as.
134
+ * Such a group forwards nobody: it PUBLISHES (a PeerTube video channel, a
135
+ * WordPress multi-author blog), which is what an Oxy channel is. The one
136
+ * inbound signal the table above has no column for.
137
+ */
138
+ const MODERATED_GROUP_KIND = 'channel';
139
+ /**
140
+ * The Oxy account kind a REMOTE actor is recorded as, read from the same table
141
+ * the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
142
+ * the table does not know (absent, misspelt, an extension type) is `personal`,
143
+ * the same default {@link localActorTypeForAccountKind} announces.
144
+ */
145
+ export function accountKindForActor(actor) {
146
+ if (typeof actor.type !== 'string')
147
+ return 'personal';
148
+ const type = INBOUND_ACTOR_TYPE_ALIASES.get(actor.type) ?? actor.type;
149
+ if (type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community && actor.postingRestrictedToMods === true)
150
+ return MODERATED_GROUP_KIND;
151
+ return ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE.get(type) ?? 'personal';
152
+ }
104
153
  /** Map common image extensions to a MIME type for an actor image `mediaType`. */
105
154
  const IMAGE_MEDIA_TYPE_BY_EXT = {
106
155
  png: 'image/png',
package/dist/esm/index.js CHANGED
@@ -62,4 +62,4 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
62
62
  * byte-identical across apps, with media resolution injected. The actor `type`
63
63
  * follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
64
64
  */
65
- export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
65
+ export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
@@ -36,28 +36,29 @@ export declare function isApActorType(value: unknown): value is ApActorType;
36
36
  /**
37
37
  * The AS2 actor types this engine will announce for a LOCAL user actor.
38
38
  *
39
- * Narrower than AS2's five actor types, and narrow ON PURPOSE — the union names
40
- * what the builder can emit, so the two absences are documented decisions rather
41
- * than oversights. Both were checked against real receiving implementations
42
- * (mastodon `a3649295`, lemmy `4ce92433`, misskey `b95e4841`, peertube
43
- * `fe0da961`), not against the spec:
39
+ * Narrower than AS2's five actor types, and narrow ON PURPOSE. Both decisions
40
+ * were checked against real receiving implementations (mastodon `a3649295`,
41
+ * lemmy `4ce92433`, misskey `b95e4841`, peertube `fe0da961`), not against the
42
+ * spec:
44
43
  *
45
44
  * - **`Application`** is the INSTANCE actor's type (see the actor router's
46
45
  * `/ap/users/instance` branch), reserved by convention for the software
47
46
  * itself. No account is the software.
48
- * - **`Group`** is refused because in the deployed fediverse it is not read as
49
- * "a collective of actors" but as a FORWARDING actor, and the two failure
50
- * modes are concrete. Lemmy reclassifies a remote `Group` as a COMMUNITY:
51
- * it is followable and the Follow is Accepted, so it looks like it worked,
52
- * and then it never shows a single post — we emit no `Announce`, and our
53
- * Notes do not resolve to a community. PeerTube is worse and louder: it
54
- * REJECTS a `Group` actor outright unless it carries `attributedTo` naming a
55
- * `Person`. An Oxy account authors its own posts and forwards nothing, so
56
- * `Group` would advertise a protocol this engine does not implement.
47
+ * - **`Group`** is announced ONLY for a `community`, because in the deployed
48
+ * fediverse it is not read as "a collective of actors" but as a FORWARDING
49
+ * actor. Lemmy reclassifies a remote `Group` as a community: followable, the
50
+ * Follow is Accepted, and then it never shows a post unless the actor
51
+ * `Announce`s its members' posts. PeerTube rejects a `Group` outright unless
52
+ * it carries `attributedTo` naming a `Person`. An account that authors its
53
+ * own posts (a project, a channel) must therefore never claim it; a community
54
+ * is the one kind whose nature IS forwarding.
57
55
  */
58
- export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service'>;
56
+ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service' | 'Group'>;
59
57
  /**
60
- * Oxy account kind → the AS2 actor type the fediverse is told about it.
58
+ * Oxy account kind → the AS2 actor type the fediverse is told about it — the
59
+ * ONE table both directions read: what Oxy announces for its own accounts here,
60
+ * and, through {@link accountKindForActor}, what it records a remote actor as.
61
+ * Changing how a kind federates is an edit to this table and nowhere else.
61
62
  *
62
63
  * `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
63
64
  * kind added to `@oxy.so/contracts` fails THIS build rather than silently
@@ -89,6 +90,8 @@ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'S
89
90
  * accepted by all four implementations' whitelists and compared in none of
90
91
  * them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
91
92
  * Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
93
+ * - **`community` → `Group`.** A forum its members post into and that
94
+ * re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
92
95
  *
93
96
  * What this does NOT do: it does not stop a remote instance offering a reply box
94
97
  * under a channel's post. NO actor type gates that in any of the four — Mastodon
@@ -103,6 +106,7 @@ export declare const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND: {
103
106
  readonly project: "Organization";
104
107
  readonly bot: "Service";
105
108
  readonly channel: "Organization";
109
+ readonly community: "Group";
106
110
  };
107
111
  /**
108
112
  * The AS2 actor type for an Oxy account kind, defaulting to `Person`.
@@ -117,6 +121,19 @@ export declare const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND: {
117
121
  * existed.
118
122
  */
119
123
  export declare function localActorTypeForAccountKind(kind: unknown): LocalActorType;
124
+ /** The fields of a remote actor document that decide its kind. */
125
+ export interface ActorKindSignals {
126
+ type?: unknown;
127
+ /** Lemmy's and PeerTube's flag: only moderators may post into this `Group`. */
128
+ postingRestrictedToMods?: unknown;
129
+ }
130
+ /**
131
+ * The Oxy account kind a REMOTE actor is recorded as, read from the same table
132
+ * the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
133
+ * the table does not know (absent, misspelt, an extension type) is `personal`,
134
+ * the same default {@link localActorTypeForAccountKind} announces.
135
+ */
136
+ export declare function accountKindForActor(actor: ActorKindSignals): AccountKind;
120
137
  /**
121
138
  * App-supplied media resolution for the actor `icon`/`image`. Each function
122
139
  * resolves a stored reference (Oxy file id or URL) to a FINAL, ready-to-serve
@@ -62,7 +62,7 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
62
62
  * byte-identical across apps, with media resolution injected. The actor `type`
63
63
  * follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
64
64
  */
65
- export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
65
+ export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, type ActorKindSignals, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
66
66
  /**
67
67
  * Supported external networks, keyed by the transport a connector speaks.
68
68
  * `instagram-graph` is Meta's Graph API Business Discovery: a read-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -7,6 +7,7 @@ import {
7
7
  isApActorType,
8
8
  LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
9
9
  localActorTypeForAccountKind,
10
+ accountKindForActor,
10
11
  type ActorMediaResolver,
11
12
  } from '../index';
12
13
 
@@ -198,6 +199,7 @@ describe('actor type by Oxy account kind', () => {
198
199
  expect(localActorTypeForAccountKind('project')).toBe('Organization');
199
200
  expect(localActorTypeForAccountKind('bot')).toBe('Service');
200
201
  expect(localActorTypeForAccountKind('channel')).toBe('Organization');
202
+ expect(localActorTypeForAccountKind('community')).toBe('Group');
201
203
  });
202
204
 
203
205
  it('never announces a human-curated channel as automated', () => {
@@ -207,10 +209,11 @@ describe('actor type by Oxy account kind', () => {
207
209
  // A channel is curated by people; only `bot` may claim automation.
208
210
  expect(localActorTypeForAccountKind('channel')).not.toBe('Service');
209
211
  expect(localActorTypeForAccountKind('bot')).toBe('Service');
210
- // `Group` is never emitted for anything: Lemmy would reclassify the actor as
211
- // a community that silently never receives content, and PeerTube rejects a
212
- // `Group` lacking `attributedTo` outright.
213
- expect(Object.values(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)).not.toContain('Group');
212
+ // `Group` is announced for a community alone: Lemmy reads it as a forwarding
213
+ // community, and PeerTube rejects a `Group` lacking `attributedTo` outright.
214
+ expect(ACCOUNT_KINDS.filter((kind) => localActorTypeForAccountKind(kind) === 'Group')).toEqual([
215
+ 'community',
216
+ ]);
214
217
  });
215
218
 
216
219
  it('covers every account kind contracts defines, with more than one answer', () => {
@@ -219,13 +222,30 @@ describe('actor type by Oxy account kind', () => {
219
222
  // Vacuity floor: a mapping that returned one constant would pass every
220
223
  // single-kind assertion above and fail this.
221
224
  const answers = ACCOUNT_KINDS.map((kind) => localActorTypeForAccountKind(kind));
222
- expect(new Set(answers).size).toBe(3);
225
+ expect(new Set(answers).size).toBe(4);
223
226
  // And specifically: the two kinds either side of the channel decision differ.
224
227
  expect(localActorTypeForAccountKind('channel')).not.toBe(
225
228
  localActorTypeForAccountKind('personal'),
226
229
  );
227
230
  });
228
231
 
232
+ it('reads every announced type back as a kind announced the same way', () => {
233
+ for (const kind of ACCOUNT_KINDS) {
234
+ const type = localActorTypeForAccountKind(kind);
235
+ expect(localActorTypeForAccountKind(accountKindForActor({ type }))).toBe(type);
236
+ }
237
+ });
238
+
239
+ it('reads the actor types and flags Oxy never announces', () => {
240
+ expect(accountKindForActor({ type: 'Application' })).toBe('bot');
241
+ expect(accountKindForActor({ type: 'Group', postingRestrictedToMods: true })).toBe('channel');
242
+ expect(accountKindForActor({ type: 'Group', postingRestrictedToMods: false })).toBe(
243
+ 'community',
244
+ );
245
+ expect(accountKindForActor({ type: 'Tombstone' })).toBe('personal');
246
+ expect(accountKindForActor({})).toBe('personal');
247
+ });
248
+
229
249
  it('falls back to Person for an absent, null, or unrecognized kind', () => {
230
250
  expect(localActorTypeForAccountKind(undefined)).toBe('Person');
231
251
  expect(localActorTypeForAccountKind(null)).toBe('Person');
@@ -49,29 +49,30 @@ export function isApActorType(value: unknown): value is ApActorType {
49
49
  /**
50
50
  * The AS2 actor types this engine will announce for a LOCAL user actor.
51
51
  *
52
- * Narrower than AS2's five actor types, and narrow ON PURPOSE — the union names
53
- * what the builder can emit, so the two absences are documented decisions rather
54
- * than oversights. Both were checked against real receiving implementations
55
- * (mastodon `a3649295`, lemmy `4ce92433`, misskey `b95e4841`, peertube
56
- * `fe0da961`), not against the spec:
52
+ * Narrower than AS2's five actor types, and narrow ON PURPOSE. Both decisions
53
+ * were checked against real receiving implementations (mastodon `a3649295`,
54
+ * lemmy `4ce92433`, misskey `b95e4841`, peertube `fe0da961`), not against the
55
+ * spec:
57
56
  *
58
57
  * - **`Application`** is the INSTANCE actor's type (see the actor router's
59
58
  * `/ap/users/instance` branch), reserved by convention for the software
60
59
  * itself. No account is the software.
61
- * - **`Group`** is refused because in the deployed fediverse it is not read as
62
- * "a collective of actors" but as a FORWARDING actor, and the two failure
63
- * modes are concrete. Lemmy reclassifies a remote `Group` as a COMMUNITY:
64
- * it is followable and the Follow is Accepted, so it looks like it worked,
65
- * and then it never shows a single post — we emit no `Announce`, and our
66
- * Notes do not resolve to a community. PeerTube is worse and louder: it
67
- * REJECTS a `Group` actor outright unless it carries `attributedTo` naming a
68
- * `Person`. An Oxy account authors its own posts and forwards nothing, so
69
- * `Group` would advertise a protocol this engine does not implement.
60
+ * - **`Group`** is announced ONLY for a `community`, because in the deployed
61
+ * fediverse it is not read as "a collective of actors" but as a FORWARDING
62
+ * actor. Lemmy reclassifies a remote `Group` as a community: followable, the
63
+ * Follow is Accepted, and then it never shows a post unless the actor
64
+ * `Announce`s its members' posts. PeerTube rejects a `Group` outright unless
65
+ * it carries `attributedTo` naming a `Person`. An account that authors its
66
+ * own posts (a project, a channel) must therefore never claim it; a community
67
+ * is the one kind whose nature IS forwarding.
70
68
  */
71
- export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service'>;
69
+ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service' | 'Group'>;
72
70
 
73
71
  /**
74
- * Oxy account kind → the AS2 actor type the fediverse is told about it.
72
+ * Oxy account kind → the AS2 actor type the fediverse is told about it — the
73
+ * ONE table both directions read: what Oxy announces for its own accounts here,
74
+ * and, through {@link accountKindForActor}, what it records a remote actor as.
75
+ * Changing how a kind federates is an edit to this table and nowhere else.
75
76
  *
76
77
  * `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
77
78
  * kind added to `@oxy.so/contracts` fails THIS build rather than silently
@@ -103,6 +104,8 @@ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'S
103
104
  * accepted by all four implementations' whitelists and compared in none of
104
105
  * them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
105
106
  * Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
107
+ * - **`community` → `Group`.** A forum its members post into and that
108
+ * re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
106
109
  *
107
110
  * What this does NOT do: it does not stop a remote instance offering a reply box
108
111
  * under a channel's post. NO actor type gates that in any of the four — Mastodon
@@ -117,6 +120,7 @@ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
117
120
  project: 'Organization',
118
121
  bot: 'Service',
119
122
  channel: 'Organization',
123
+ community: 'Group',
120
124
  } as const satisfies Record<AccountKind, LocalActorType>;
121
125
 
122
126
  /**
@@ -135,6 +139,58 @@ export function localActorTypeForAccountKind(kind: unknown): LocalActorType {
135
139
  return isAccountKind(kind) ? LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
136
140
  }
137
141
 
142
+ /**
143
+ * The inverse of {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}, derived from it. Three
144
+ * kinds share `Organization`, so a type reads back as the FIRST kind the table
145
+ * lists for it (`Organization` → `organization`); nothing on the wire tells a
146
+ * remote project from a remote organization.
147
+ */
148
+ const ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE: ReadonlyMap<string, AccountKind> = (() => {
149
+ const inverse = new Map<string, AccountKind>();
150
+ for (const [kind, type] of Object.entries(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
151
+ if (!inverse.has(type)) inverse.set(type, kind as AccountKind);
152
+ }
153
+ return inverse;
154
+ })();
155
+
156
+ /**
157
+ * AS2 actor types Oxy never announces but receives, and the announced type each
158
+ * is read as. `Application` is a non-human software actor — what `Service`
159
+ * already says, and half of Mastodon's own `bot?`.
160
+ */
161
+ const INBOUND_ACTOR_TYPE_ALIASES: ReadonlyMap<string, LocalActorType> = new Map([
162
+ ['Application', 'Service'],
163
+ ]);
164
+
165
+ /**
166
+ * The kind a `Group` that only its moderators may post into is recorded as.
167
+ * Such a group forwards nobody: it PUBLISHES (a PeerTube video channel, a
168
+ * WordPress multi-author blog), which is what an Oxy channel is. The one
169
+ * inbound signal the table above has no column for.
170
+ */
171
+ const MODERATED_GROUP_KIND: AccountKind = 'channel';
172
+
173
+ /** The fields of a remote actor document that decide its kind. */
174
+ export interface ActorKindSignals {
175
+ type?: unknown;
176
+ /** Lemmy's and PeerTube's flag: only moderators may post into this `Group`. */
177
+ postingRestrictedToMods?: unknown;
178
+ }
179
+
180
+ /**
181
+ * The Oxy account kind a REMOTE actor is recorded as, read from the same table
182
+ * the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
183
+ * the table does not know (absent, misspelt, an extension type) is `personal`,
184
+ * the same default {@link localActorTypeForAccountKind} announces.
185
+ */
186
+ export function accountKindForActor(actor: ActorKindSignals): AccountKind {
187
+ if (typeof actor.type !== 'string') return 'personal';
188
+ const type = INBOUND_ACTOR_TYPE_ALIASES.get(actor.type) ?? actor.type;
189
+ if (type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community && actor.postingRestrictedToMods === true)
190
+ return MODERATED_GROUP_KIND;
191
+ return ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE.get(type) ?? 'personal';
192
+ }
193
+
138
194
  /** Map common image extensions to a MIME type for an actor image `mediaType`. */
139
195
  const IMAGE_MEDIA_TYPE_BY_EXT: Record<string, string> = {
140
196
  png: 'image/png',
package/src/index.ts CHANGED
@@ -119,6 +119,8 @@ export {
119
119
  createLocalActorBuilder,
120
120
  normalizeAlsoKnownAs,
121
121
  localActorTypeForAccountKind,
122
+ accountKindForActor,
123
+ type ActorKindSignals,
122
124
  isApActorType,
123
125
  AP_ACTOR_TYPES,
124
126
  LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,