Skip to content

Routing Note and activity IDs under their Actor so they dereference - #324

Merged
aaronjae22 merged 3 commits into
mainfrom
object-routes
Oct 9, 2026
Merged

aaronjae22 merged 3 commits into
mainfrom
object-routes

Conversation

@aaronjae22

Copy link
Copy Markdown
Collaborator

This has been on my backlog for quite some time and its something I wanted to implement. I found a good reason to do it now while I am closing the source implementation.

Every Note and Activity we serve has an id but and fetching it returned 404 to everyone, including the owner.

public Note   anonymous    GET /api/notes/1/             -> 404
public Note   owner token  GET /api/notes/1/             -> 404
private Note  owner token  GET /api/notes/2/             -> 404
public Like   anonymous    GET /api/activities/like/1/   -> 404

These are my reason to implement this now:

  1. ActivityPub requires it and §3.1 says identifiers "must fall into one of the following groups: Publicly dereferencable URIs… and An ID explicitly specified as the JSON null object". An id nobody can fetch is in neither group.
  2. The liked collection points at IDs since Like object embedding #322 and it serves a non-public Note as just its id.
  3. Breadcrumbs point at IDs. A destination copying the content stores our id in previously.

Now Objects live under their Actor

/api/actors/<pk>/notes/<note_pk>/
/api/actors/<pk>/activities/create/<activity_pk>/
/api/actors/<pk>/activities/like/<activity_pk>/
/api/actors/<pk>/activities/follow/<activity_pk>/

I nested them because LOLA's section on hosting redirects for objects indicates that "If the original Actor identity is part of the URL of the Object that is no longer served, the source server can use that to look up the Actor and find the movedTo value."*

With plain numeric IDs the server "may need to keep a list of moved Objects and what Actor they were once associated with". LOLA requires a way for a user to delete their content while the Actor stays up with movedTo. With the Actor in the URL, a deleted object's URL still names its Actor, so a later redirect from the source to the destination won't need an extra table. It also puts objects next to /api/actors/<pk>/outbox/ and the other per-Actor endpoints.

The URL alone doesn't prove ownership but the lookup does. Each view queries model.objects.filter(pk=<object_pk>, actor=<actor>). Alice's Note requested under Bob's path is 404, exactly as if it didn't exist.

Access follows the object's own visibility:

State of the ojbect Caller Response
public no token, or a token bound to its owner 200, the same JSON the collections serve
public a token bound to another actor 403 actor_mismatch, as on every dual-mode endpoint
not public a token bound to its owner 200
not public anyone else 404 object_not_found
missing, or under the wrong Actor anyone 404 object_not_found, the same answer

A non-public object gives exactly the same answer as one that doesn't exist (ActivityPub §3.2: a server "which does not wish to disclose the existence of a private target MAY instead respond with a status code of 404 Not Found"). A missing Actor gives the existing 404 actor_not_found.

Output for the same objects as above:

public Note   anonymous     GET /api/actors/1/notes/1/             -> 200 application/activity+json
private Note  anonymous     GET /api/actors/1/notes/2/             -> 404 object_not_found
private Note  owner token   GET /api/actors/1/notes/2/             -> 200
public Note   under Bob     GET /api/actors/2/notes/1/             -> 404 object_not_found
public Like   anonymous     GET /api/actors/1/activities/like/1/   -> 200
missing Note  anonymous     GET /api/actors/1/notes/999999/        -> 404 object_not_found

@aaronjae22
aaronjae22 requested a review from lisad October 6, 2026 02:54
@aaronjae22 aaronjae22 self-assigned this Oct 6, 2026
@aaronjae22
aaronjae22 added this pull request to stack #321 October 6, 2026 02:54

@lisad lisad 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.

OK to merge but I suggested some refactors

)


def build_object_not_found_error(request=None):

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.

This nested "build_x" approach looks very much like a functional approach for what could be simpler as an object-oriented approach - the method is a very thin wrapper. Error response is already an object concept in python requests, and already an inheritance-based one, so a specific "ObjectNotFound" error could be built on the generic "ResourceNotFound" error

Even cleaner (and requiring less utils) is raising custom exceptions and the custom exceptions get handled in post-view common handling that adds the error code.

Looking at this whole util file as more gets added to it and its purpose gets more clear, I am seeing what it does and guessing that it could be replaced with 10 lines even taking this basic functional approach.

  • it adds more detailed codes on top of 400, 404, 500 with constants
  • it adds full-sentence descriptions
  • adds timestamps (redundant)
  • adds new ID (generated once and does not correlate with anything)

Some of these jobs could be done with simple lookups (if the INSUFFICIENT_SCOPE constant is passed or the InsufficientScopeException is thrown, lookup the correct HTTP status code and explanation to return with the Response. ). Other jobs could be done in the Response object or exception handling. The new ID and timestamp jobs don't seem to be adding value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, you're absolutely right with it. I'll iterate over it.

Comment thread testbed/core/views/api.py
@authentication_classes([OptionalOAuth2Authentication])
@activitypub_content
@actor_required
def follow_activity_detail(request, pk, actor, object_pk):

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.

These are once again very thin, repetitive wrappers

probably a single "serve_object" view could be called by multiple different
URL mappings, each one with a different object Type passing to it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I agreed and that's actually how I first wrote it with one view and each route passing its model and builder but I switched to four views to match the rest of api_urls.py. I'll deal with it in the follow-up PR

@aaronjae22
aaronjae22 force-pushed the object-routes branch 2 times, most recently from b8e9b33 to ee71782 Compare October 9, 2026 16:25
Base automatically changed from liked-private-likes to main October 9, 2026 16:33
@aaronjae22
aaronjae22 merged commit 91b431a into main Oct 9, 2026
3 checks passed
@aaronjae22
aaronjae22 deleted the object-routes branch October 9, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants