Activity log GraphQL API - #101
2chanhaeng wants to merge 10 commits into
Conversation
cf6450c to
b9e1298
Compare
b9e1298 to
ccd5d5e
Compare
Implement the activity-log API from plans/12: immutable public-key versions, delivery observations, inbox recording, synchronous outbound hooks, and member-authorized Relay connections. Preserve original inbound JSON and keep key history independent of the federation cache. The contributor requested implementation of the supplied plan and an independent Claude Fable 5 review outside the sandbox. Codex generated the schema, migration, recording helpers, GraphQL API, integration, tests, and documentation. Implementation checks led to nullable GraphQL JSON payloads, request-local verification-key snapshots, and an empty key dispatcher until local signing keys are implemented. Fable identified orphan key observations on unclaimed hosts; Codex moved the instance gate before verification and added regression coverage. Fable's second review found no issues. Agent validation: mise run build, mise run check, and all 240 tests passed. A running development server accepted a signed inbox request (202), rejected an unsigned request (401), and persisted the expected logs and key version. Tarball installation with locally installed external dependencies verified new exports, bundled migrations, and drfed-server --help. Human review and verification are not asserted by these agent-run checks. Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
Extend activity_logs with the verification mechanism and result, the raw request body, headers, and URL, the response body, every type, the recipient IRIs, and the completion time. Add activity_log_actors, which links an inbound log to the actors it addresses, and activity_log_attempts, which keeps every outbound delivery attempt. Add the acknowledged and abandoned statuses and the unattempted and unobserved verification results. A payload PostgreSQL cannot store as jsonb is kept as NULL while the raw body stays, and NUL in stored errors and response bodies becomes U+FFFD. receiveInbound() marks a log received once a queue worker handles its activity. #12 Claude Code wrote this change from Codex reviews of the branch against the issue, as directed by the contributor. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra
tracking.ts keeps, per request, the public key cache entries, spans, measurements, and responses Fedify produces. telemetry.ts supplies the tracer and meter providers that collect Fedify's documented spans, events, and metrics, and reads response status codes from undici's diagnostics_channel. describe.ts derives log columns from an activity, keeping only IRIs that parse as URLs and no NUL. addressing.ts finds the local actors an activity addresses, including members of stored collections. instanceUrl() composes the canonical URL of a path on an instance. #12 Claude Code wrote this change from Codex reviews of the branch against the issue, as directed by the contributor. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra
Wrap the outbox queue so that every attempt of Fedify's worker settles its log and adds an attempt. Retries and exhaustion come from the activitypub.outbox.activity metric, and the queue reports nativeRetrial as false so that every retry goes through Fedify. Log IDs travel on queue messages, so repeated deliveries settle their own rows. deliverActivity() strips bto and bcc, skips recipients without an id as Fedify does, and counts a delivery as sent only on an activitypub.activity.sent event; one Fedify never sent or enqueued settles as permanently_failed. The status code follows redirects from the inbox to the final response. The tests run Fedify's own delivery against a local inbox server. #12 Claude Code wrote this change from Codex reviews of the branch against the issue, as directed by the contributor. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra
The recorder no longer verifies requests itself. Verdicts come from Fedify's verification spans and the activitypub.signature.verification.duration metric, and the key from the cache entries of that verification's own key fetches, so the stored key version is the one Fedify used. A key that could not be fetched is recorded as key_fetch_error with the reason, and a verified proof whose actor Fedify refused as verified but rejected. A log's created time is when the request arrived. Requests whose handling throws are logged before the error is rethrown. #12 Claude Code wrote this change from Codex reviews of the branch against the issue, as directed by the contributor. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra
ActivityLog exposes the verification mechanism and result, the raw request, the response body, the completion time, and its attempts. Actor.activityLogs goes through activity_log_actors, and Key.versions becomes a connection. Restore the import order of schema.ts. #12 Claude Code wrote this change from Codex reviews of the branch against the issue, as directed by the contributor. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra
createFederation() hands Fedify the tracking KV store and the tracer and meter providers, wraps the outbox queue, and returns a TrackedFederation, the only federation createInboundRecorder() accepts. Inbox listeners call markHandled(), which tells a received activity from an acknowledged duplicate. The recorder takes the root origin instead of the KV store. Document the contract. #12 Claude Code wrote this change from Codex reviews of the branch against the issue, as directed by the contributor. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra
Fedify 2.4.0 changes several behaviors DrFed relies on:
- Private addresses are refused for outbound delivery too, redirects
included. drfed-server allows them, so that it can deliver to apps
under development on a local network, and so do the tests that
deliver to a local inbox. createFederation() leaves the option to
its callers, since Fedify refuses it together with the loader
factories the inbound tests pass.
- KvKeyCache keeps each public key under the generation segment "2"
and with a 30-day TTL. createKeyCache() and trackedKey() follow
that layout, so the key a verification used is recorded again.
- Object tombstones are served with HTTP 410.
- Fedify fetches the actor on every inbox request to check that it
owns the key, so the cache test counts only fetches of the key.
- Fedify follows every redirect itself, signed or not, reading a
Location outside ASCII as Latin-1.
The contributor bumped the Fedify catalog entries, allowed private
addresses in drfed-server and in createFederation(), and asked Claude
Code to add the option wherever it is needed and to fix the tests the
upgrade broke. Claude Code traced each failure to the Fedify change
behind it, moved the option from createFederation() to the callers
that need it, and wrote the fixes above. mise run check and mise run
test pass.
Assisted-by: Claude Code:claude-opus-5-5
ccd5d5e to
f8bab57
Compare
dahlia
left a comment
There was a problem hiding this comment.
The scoped key-cache handling needs a fix: a successfully verified inbound activity can currently be recorded without retaining the verification key.
I also left comments on addressing provenance, GraphQL edge metadata, and attempt pagination. I would like us to agree on a common model for locally created and remotely received activities, with delivery history represented separately.
The database naming convention and additional integrity checks can be discussed in follow-up issues; they do not need to block the MVP.
| keyIri: string, | ||
| options: Loaders = {}, | ||
| ): Promise<CryptographicKey | Multikey | null> { | ||
| const entry = JSON.stringify([generation, keyIri]); |
There was a problem hiding this comment.
Preserve verification keys from Fedify's scoped cache
trackedKey() only reads ["2", keyIri], but Fedify 2.4 stores keys at FEP-ef61 compatible identifiers under ["__compatible", scope, keyIri], with the serialized key inside a { key, expires, … } entry. See the 2.4.0 scoped cache implementation. Those keys are therefore missed here.
I reproduced this with an Ed25519 Object Integrity Proof whose verification method was https://remote.example/.well-known/apgateway/did:key:…/actor#key. Fedify returned HTTP 202, and the log had status = received and verificationResult = verified, but verificationKeyId was NULL and no key_versions row was created. The key was present in Fedify's __compatible/multikey cache. Object Integrity Proof support is documented in Fedify's inbox signature verification guide.
Please handle the scoped cache entries and add a regression test so that this path also preserves the public key used for verification, as requested in #12.
| ...row, | ||
| actorId, | ||
| addressed: true, | ||
| viaCollectionIri: |
There was a problem hiding this comment.
Could we preserve every addressing path when an actor belongs to more than one addressed collection? inboundActorRows() keeps only the last collection IRI: [A, B] retains B, while [B, A] retains A. A direct recipient entry also clears the collection provenance. The (logId, actorId) primary key and single viaCollectionIri column cannot retain those paths together.
Keeping one feed entry per actor makes sense, but a debugging tool should still be able to explain how that actor was addressed. A child table for the addressing paths would preserve that information without duplicating feed entries. If retaining only a representative path is intentional, could we document the selection rule and the information it discards?
| authScopes: (instance) => access(instance.localId), | ||
| }), | ||
| ); | ||
| builder.drizzleObjectField("actors", "activityLogs", (t) => |
There was a problem hiding this comment.
Could we expose the actor-specific relationship metadata on this connection's edges? The database records inboxOwner, addressed, sender, and viaCollectionIri, but the API currently returns only the activity nodes. For a shared-inbox delivery, ActivityLog.actor can be null, so a client cannot tell why an entry appears in this actor's feed. Reconstructing it from the payload would also miss the collection membership observed at delivery time.
These fields describe the relationship to the selected actor, so the edge seems like the right place for them. Account.instances already uses edge fields for membership metadata, and the Relay connection specification allows additional edge fields. If we preserve multiple addressing paths, the edge should expose those as well.
| "inbound request whose handling threw is the exception. Null for " + | ||
| "accepted ones.", | ||
| }), | ||
| attempts: t.relation("attempts", { |
There was a problem hiding this comment.
Could we make attempts a paginated connection before publishing this API? This relation loads the entire attempt history. Fedify's default retry policy keeps it small, but callers can provide a custom retry policy with no fixed attempt limit, so the schema does not guarantee a bounded list.
A connection would let clients request only the history they need. The existing (logId, created, id) index also fits cursor pagination. Changing a list field to a connection later would break existing queries, so I would prefer to settle this contract here.
| "activity_logs", | ||
| { | ||
| id: uuid().$type<Uuid>().primaryKey(), | ||
| instanceId: uuid("instance_id") |
There was a problem hiding this comment.
The new SQL columns use snake_case, while several existing tables use physical camelCase column names. I think we should discuss the project-wide naming convention and any migration of existing tables in a separate issue.
My preference is PascalCase for TypeScript types, camelCase for TypeScript values and properties, and snake_case for database table and column names. Could we keep the new schema in this PR going in that direction, as it already does here, and handle the existing database names separately?
| .notNull() | ||
| .references(() => instances.id, { onDelete: "cascade" }), | ||
| /** The owner of the inbox the request arrived at, or the sending actor. */ | ||
| actorId: uuid("actor_id") |
There was a problem hiding this comment.
A possible follow-up: could we enforce that the local actors attached to a delivery belong to its instance? These foreign keys independently validate the instance and actor, but do not validate their relationship. I confirmed that recordOutbound() accepts an instanceId from one instance and an actorId belonging to another. The actor-link table has the same invariant to consider.
The current federation integration checks the instance when selecting actors, so this is an integrity-hardening suggestion rather than a demonstrated failure in that path. Validation in the recording functions or suitable database constraints could protect future callers. This can be tracked in a separate issue if it falls outside the MVP scope.
| }); | ||
|
|
||
| const ActivityLogRef = builder.drizzleNode("activityLogs", { | ||
| name: "ActivityLog", |
There was a problem hiding this comment.
Could we model locally created and remotely received activities through a common Activity type? My preference for that name is about a unified domain model: both should be activities in the same API, with their origin represented explicitly.
The existing activities table represents ActivityPub objects, while activity_logs represents delivery observations and already covers inbound and outbound traffic. I would keep that distinction: an Activity could have multiple ActivityDelivery records, each with its inbox, direction, verification result and processing status, and outbound deliveries could have ActivityDeliveryAttempt records. Sending one activity to several inboxes or receiving it more than once would then preserve one activity identity alongside separate delivery histories.
Delivery records should still retain the payload observed at the time. Malformed requests or requests without an activity ID may have no linked Activity, and unverified input claiming an existing IRI must not overwrite the stored activity. That keeps the debugging information available without treating every received payload as a trusted activity.
Could we discuss this relationship alongside #88 and agree how much belongs in this PR? Simply renaming the current ActivityLog node to Activity would leave activity identity and individual deliveries conflated; I would prefer to establish the shared activity model and name the delivery records accordingly.
…rver Restore isolated GraphQL test databases from a migrated PGlite snapshot to avoid repeated initialization and migrations. Run tests after the server build without requiring the unused web build. Codex was asked to apply snapshot reuse and compare test times. It implemented the harness change and measured mise run test at 59.77s before and 39.27s after. All 286 tests and mise run check passed. The user modified the `test` task to depend only on `build:server` so that the frontend would not be built when the `test` task is run directly. Assisted-by: Codex:gpt-6-astra
Closes #12.
This PR records ActivityPub deliveries in both directions and exposes them through GraphQL as
Instance.activityLogsandActor.activityLogs. The logs are newest first and can be filtered by direction, status, and type. The diff adds about 12,000 lines, but only about 4,000 of those contain logic. The rest is for tests, migration snapshots, etc.Changes
@drfed/models): adds theactivity_logs,activity_log_actors, andactivity_log_attemptstables, pluskeysandkey_versionsfor public key history.recordInbound,receiveInbound,recordOutbound, andsettleOutboundwrite the logs. Bodies PostgreSQL cannot store are kept raw, withpayload = NULL.createInboundRecorderwraps the federation HTTP surface and logs every inboxPOST. DrFed never verifies a request again. The verification mechanism, result, and key version come from the spans and metrics that Fedify documents and from its key cache.deliverActivitylogs one row per destination inbox. A wrapped outbox queue settles each attempt that Fedify's worker makes, including retries and abandonment.ActivityLogexposes the raw request, the response, the verification details, and the attempts.Key.versionsis now a connection. Only local instance members and administrators can read logs, including through Relay node IDs.createFederationreturns aTrackedFederation.drfed-serverroutes requests through the recorder. Fedify is upgraded to 2.4.0.AI disclosure
Claude Code (claude-opus-5-5, claude-fable-5-1) and Codex (gpt-6-astra) helped write this change, and every AI-assisted commit has
Assisted-bytrailers. The contributor reviewed the changes and verified them withmise run build,mise run check, andmise run test.