Skip to content

Commit 663c0e1

Browse files
committed
fix(api): tighten ActivityPub schema proof
Guarantee schema parity with the Mastodon actor fixture and remove Schema.Unknown from the ActivityPub API boundary. Proof obligations: required ActivityPub object fields decode iff their literal type/context invariants hold; ordered collection items and ForgeFed raw payloads are JSON values; unsupported followers pages fail with typed 400 errors.
1 parent 1700485 commit 663c0e1

5 files changed

Lines changed: 331 additions & 15 deletions

File tree

packages/api/src/api/activitypub-schema.ts

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import * as Schema from "effect/Schema"
33
import {
44
activityStreamsJsonLdContext,
55
forgeFedJsonLdContext,
6-
securityJsonLdContext
6+
securityJsonLdContext,
7+
socialWebWebfingerJsonLdContext
78
} from "./contracts.js"
89

910
export type JsonPrimitive = boolean | number | string | null
@@ -25,18 +26,59 @@ const OptionalString = Schema.optional(Schema.String)
2526
const OptionalBoolean = Schema.optional(Schema.Boolean)
2627
const JsonObjectSchema = Schema.Record({ key: Schema.String, value: JsonValueSchema })
2728
const JsonLdContextEntrySchema = Schema.Union(Schema.String, JsonObjectSchema)
29+
const JsonLdIdMappingSchema = Schema.Struct({
30+
"@id": Schema.String,
31+
"@type": OptionalString
32+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
2833

2934
export const ActivityForgeFedJsonLdContextSchema = Schema.Tuple(
3035
Schema.Literal(activityStreamsJsonLdContext),
3136
Schema.Literal(forgeFedJsonLdContext)
3237
)
3338

34-
export const ActorJsonLdContextSchema = Schema.Tuple(
39+
export const LocalActorJsonLdContextSchema = Schema.Tuple(
3540
Schema.Literal(activityStreamsJsonLdContext),
3641
Schema.Literal(securityJsonLdContext),
3742
Schema.Literal(forgeFedJsonLdContext)
3843
)
3944

45+
export const MastodonActorContextExtensionsSchema = Schema.Struct({
46+
manuallyApprovesFollowers: OptionalString,
47+
toot: OptionalString,
48+
featured: Schema.optional(JsonLdIdMappingSchema),
49+
featuredTags: Schema.optional(JsonLdIdMappingSchema),
50+
alsoKnownAs: Schema.optional(JsonLdIdMappingSchema),
51+
movedTo: Schema.optional(JsonLdIdMappingSchema),
52+
schema: OptionalString,
53+
PropertyValue: OptionalString,
54+
value: OptionalString,
55+
discoverable: OptionalString,
56+
suspended: OptionalString,
57+
memorial: OptionalString,
58+
indexable: OptionalString,
59+
attributionDomains: Schema.optional(JsonLdIdMappingSchema),
60+
showFeatured: OptionalString,
61+
showMedia: OptionalString,
62+
showRepliesInMedia: OptionalString,
63+
gts: OptionalString,
64+
interactionPolicy: Schema.optional(JsonLdIdMappingSchema),
65+
canQuote: Schema.optional(JsonLdIdMappingSchema),
66+
automaticApproval: Schema.optional(JsonLdIdMappingSchema),
67+
manualApproval: Schema.optional(JsonLdIdMappingSchema)
68+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
69+
70+
export const MastodonActorJsonLdContextSchema = Schema.Tuple(
71+
Schema.Literal(activityStreamsJsonLdContext),
72+
Schema.Literal(securityJsonLdContext),
73+
Schema.Literal(socialWebWebfingerJsonLdContext),
74+
MastodonActorContextExtensionsSchema
75+
)
76+
77+
export const ActorJsonLdContextSchema = Schema.Union(
78+
LocalActorJsonLdContextSchema,
79+
MastodonActorJsonLdContextSchema
80+
)
81+
4082
export const JsonLdContextSchema = Schema.Union(
4183
Schema.String,
4284
JsonObjectSchema,
@@ -60,8 +102,8 @@ export const ForgeFedTicketSchema = Schema.Struct({
60102
url: OptionalString,
61103
context: OptionalString,
62104
workType: OptionalString,
63-
attachment: Schema.optional(Schema.Array(Schema.Unknown)),
64-
raw: Schema.optional(Schema.Unknown)
105+
attachment: Schema.optional(Schema.Array(JsonValueSchema)),
106+
raw: Schema.optional(JsonValueSchema)
65107
})
66108

67109
export const ActivityPubPublicKeySchema = Schema.Struct({
@@ -79,11 +121,43 @@ const ActivityPubInteractionPolicySchema = Schema.Record({
79121
value: JsonValueSchema
80122
})
81123

124+
const ActivityPubImageSchema = Schema.Struct({
125+
type: OptionalString,
126+
mediaType: OptionalString,
127+
url: OptionalString,
128+
name: OptionalString
129+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
130+
131+
const ActivityPubActorAttachmentSchema = Schema.Struct({
132+
type: OptionalString,
133+
name: OptionalString,
134+
value: OptionalString
135+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
136+
137+
const ActivityPubActorTagSchema = Schema.Struct({
138+
type: OptionalString,
139+
name: OptionalString,
140+
href: OptionalString,
141+
id: OptionalString,
142+
icon: Schema.optional(ActivityPubImageSchema),
143+
updated: OptionalString
144+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
145+
146+
const ActivityPubInteractionApprovalSchema = Schema.Struct({
147+
automaticApproval: Schema.optional(Schema.Array(Schema.String)),
148+
manualApproval: Schema.optional(Schema.Array(Schema.String))
149+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
150+
151+
const MastodonInteractionPolicySchema = Schema.Struct({
152+
canFeature: Schema.optional(ActivityPubInteractionApprovalSchema),
153+
canQuote: Schema.optional(ActivityPubInteractionApprovalSchema)
154+
}, Schema.Record({ key: Schema.String, value: JsonValueSchema }))
155+
82156
export const ActivityPubPersonSchema = Schema.Struct({
83-
"@context": JsonLdContextSchema,
157+
"@context": ActorJsonLdContextSchema,
84158
type: Schema.Literal("Person"),
85159
id: Schema.String,
86-
name: Schema.String,
160+
name: OptionalString,
87161
preferredUsername: Schema.String,
88162
summary: Schema.String,
89163
inbox: Schema.String,
@@ -102,13 +176,20 @@ export const ActivityPubPersonSchema = Schema.Struct({
102176
indexable: OptionalBoolean,
103177
published: OptionalString,
104178
memorial: OptionalBoolean,
179+
alsoKnownAs: Schema.optional(Schema.Array(Schema.String)),
180+
movedTo: OptionalString,
181+
suspended: OptionalBoolean,
182+
attributionDomains: Schema.optional(Schema.Array(Schema.String)),
183+
icon: Schema.optional(ActivityPubImageSchema),
184+
image: Schema.optional(ActivityPubImageSchema),
185+
devices: OptionalString,
105186
showFeatured: OptionalBoolean,
106187
showMedia: OptionalBoolean,
107188
showRepliesInMedia: OptionalBoolean,
108-
interactionPolicy: Schema.optional(ActivityPubInteractionPolicySchema),
189+
interactionPolicy: Schema.optional(Schema.Union(MastodonInteractionPolicySchema, ActivityPubInteractionPolicySchema)),
109190
featuredCollections: OptionalString,
110-
tag: Schema.optional(Schema.Array(JsonValueSchema)),
111-
attachment: Schema.optional(Schema.Array(JsonValueSchema))
191+
tag: Schema.optional(Schema.Array(ActivityPubActorTagSchema)),
192+
attachment: Schema.optional(Schema.Array(ActivityPubActorAttachmentSchema))
112193
})
113194

114195
export const ActivityPubFollowActivitySchema = Schema.Struct({
@@ -129,7 +210,7 @@ export const ActivityPubOrderedCollectionSchema = Schema.Struct({
129210
first: OptionalString,
130211
last: OptionalString,
131212
current: OptionalString,
132-
orderedItems: Schema.optionalWith(Schema.Array(Schema.Unknown), { default: () => [] })
213+
orderedItems: Schema.optionalWith(Schema.Array(JsonValueSchema), { default: () => [] })
133214
})
134215

135216
export const ActivityPubOrderedCollectionPageSchema = Schema.Struct({
@@ -140,5 +221,5 @@ export const ActivityPubOrderedCollectionPageSchema = Schema.Struct({
140221
partOf: Schema.String,
141222
next: OptionalString,
142223
prev: OptionalString,
143-
orderedItems: Schema.Array(Schema.Unknown)
224+
orderedItems: Schema.Array(JsonValueSchema)
144225
})

packages/api/src/api/contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ export type ContainerTaskSnapshot = {
556556
export const activityStreamsJsonLdContext = "https://www.w3.org/ns/activitystreams" as const
557557
export const forgeFedJsonLdContext = "https://forgefed.org/ns" as const
558558
export const securityJsonLdContext = "https://w3id.org/security/v1" as const
559+
export const socialWebWebfingerJsonLdContext = "https://purl.archive.org/socialweb/webfinger" as const
559560
export const activityForgeFedJsonLdContext = [
560561
activityStreamsJsonLdContext,
561562
forgeFedJsonLdContext

packages/api/src/services/federation.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { promises as fs } from "node:fs"
1111
import { dirname, join } from "node:path"
1212

13+
import type { JsonValue } from "../api/activitypub-schema.js"
1314
import type {
1415
ActivityPubFollowActivity,
1516
ActivityPubOrderedCollection,
@@ -126,6 +127,24 @@ const isRecord = (value: unknown): value is JsonRecord =>
126127
const asRecord = (value: unknown): JsonRecord | null =>
127128
isRecord(value) ? value : null
128129

130+
const isJsonValue = (value: unknown): value is JsonValue => {
131+
if (
132+
value === null ||
133+
typeof value === "string" ||
134+
typeof value === "number" ||
135+
typeof value === "boolean"
136+
) {
137+
return true
138+
}
139+
if (Array.isArray(value)) {
140+
return value.every(isJsonValue)
141+
}
142+
return isRecord(value) && Object.values(value).every(isJsonValue)
143+
}
144+
145+
const readJsonValue = (value: unknown): JsonValue | undefined =>
146+
isJsonValue(value) ? value : undefined
147+
129148
/**
130149
* Extracts string JSON-LD context entries from a boundary value.
131150
*
@@ -598,6 +617,16 @@ const publicKeyForContext = (context: FederationContext): ActivityPubPublicKey =
598617
publicKeyPem: ensureLocalActorKeys().publicKeyPem
599618
})
600619

620+
const followActivityJson = (activity: ActivityPubFollowActivity): JsonValue => ({
621+
"@context": [...activity["@context"]],
622+
id: activity.id,
623+
type: activity.type,
624+
actor: activity.actor,
625+
object: activity.object,
626+
...(activity.to === undefined ? {} : { to: [...activity.to] }),
627+
...(activity.capability === undefined ? {} : { capability: activity.capability })
628+
})
629+
601630
export const makeFederationActorDocument = (
602631
context: FederationContext
603632
): ActivityPubPerson => ({
@@ -621,7 +650,7 @@ export const makeFederationActorDocument = (
621650
export const makeFederationOutboxCollection = (
622651
context: FederationContext
623652
): ActivityPubOrderedCollection => {
624-
const orderedItems = listFollowSubscriptions().map((subscription) => subscription.activity)
653+
const orderedItems = listFollowSubscriptions().map((subscription) => followActivityJson(subscription.activity))
625654
return {
626655
"@context": activityForgeFedJsonLdContext,
627656
type: "OrderedCollection",
@@ -693,9 +722,9 @@ const readTicketSource = (payload: JsonRecord): string | ForgeFedTicketSource |
693722
return content === undefined && mediaType === undefined ? undefined : { content, mediaType }
694723
}
695724

696-
const readTicketAttachment = (payload: JsonRecord): ReadonlyArray<unknown> | undefined => {
725+
const readTicketAttachment = (payload: JsonRecord): ReadonlyArray<JsonValue> | undefined => {
697726
const raw = payload["attachment"]
698-
return Array.isArray(raw) ? raw : undefined
727+
return Array.isArray(raw) && raw.every(isJsonValue) ? raw : undefined
699728
}
700729

701730
const parseTicket = (
@@ -718,6 +747,7 @@ const parseTicket = (
718747
const summary = yield* _(readRequiredString(payload, "summary", "ForgeFed ticket"))
719748
const content = yield* _(readRequiredString(payload, "content", "ForgeFed ticket"))
720749
const id = readOptionalString(payload, "id") ?? `urn:docker-git:forgefed:ticket:${randomUUID()}`
750+
const raw = readJsonValue(payload)
721751

722752
return {
723753
id,
@@ -732,7 +762,7 @@ const parseTicket = (
732762
context: readOptionalString(payload, "context"),
733763
workType: readOptionalString(payload, "workType"),
734764
attachment: readTicketAttachment(payload),
735-
raw: payload
765+
...(raw === undefined ? {} : { raw })
736766
}
737767
})
738768

0 commit comments

Comments
 (0)