Skip to content

Activity log GraphQL API - #101

Open
2chanhaeng wants to merge 10 commits into
mainfrom
feat/activity-log-api
Open

2chanhaeng wants to merge 10 commits into
mainfrom
feat/activity-log-api

Conversation

@2chanhaeng

Copy link
Copy Markdown
Member

Closes #12.

This PR records ActivityPub deliveries in both directions and exposes them through GraphQL as Instance.activityLogs and Actor.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

  • Storage (@drfed/models): adds the activity_logs, activity_log_actors, and activity_log_attempts tables, plus keys and key_versions for public key history. recordInbound, receiveInbound, recordOutbound, and settleOutbound write the logs. Bodies PostgreSQL cannot store are kept raw, with payload = NULL.
  • Inbound: createInboundRecorder wraps the federation HTTP surface and logs every inbox POST. 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.
  • Outbound: deliverActivity logs one row per destination inbox. A wrapped outbox queue settles each attempt that Fedify's worker makes, including retries and abandonment.
  • GraphQL: ActivityLog exposes the raw request, the response, the verification details, and the attempts. Key.versions is now a connection. Only local instance members and administrators can read logs, including through Relay node IDs.
  • Wiring: createFederation returns a TrackedFederation. drfed-server routes 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-by trailers. The contributor reviewed the changes and verified them with mise run build, mise run check, and mise run test.

@2chanhaeng 2chanhaeng added this to the DrFed 0.1.0 milestone Oct 1, 2026
@2chanhaeng
2chanhaeng requested review from dahlia, dodok8 and sij411 October 1, 2026 06:20
@2chanhaeng 2chanhaeng added the enhancement New feature or request label Oct 1, 2026
@2chanhaeng
2chanhaeng force-pushed the feat/activity-log-api branch from cf6450c to b9e1298 Compare October 1, 2026 06:20
@2chanhaeng
2chanhaeng force-pushed the feat/activity-log-api branch from b9e1298 to ccd5d5e Compare October 1, 2026 06:33
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
@2chanhaeng
2chanhaeng force-pushed the feat/activity-log-api branch from ccd5d5e to f8bab57 Compare October 1, 2026 07:05

@dahlia dahlia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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", {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

GraphQL API for activity log

2 participants