Skip to content

feat: PubkyAppCollectionPointer (unified own/follow primitive) - #119

Draft
SHAcollision wants to merge 5 commits into
feat/spec-v0.5.0-collection-variantfrom
feat/spec-v0.5.0-collection-pointer
Draft

feat: PubkyAppCollectionPointer (unified own/follow primitive)#119
SHAcollision wants to merge 5 commits into
feat/spec-v0.5.0-collection-variantfrom
feat/spec-v0.5.0-collection-pointer

Conversation

@SHAcollision

@SHAcollision SHAcollision commented May 14, 2026

Copy link
Copy Markdown
Collaborator

[DRAFT] Stacked on the unmerged feat/spec-v0.5.0-collection-variant (PR #116). Will auto-retarget to main after #116 merges.

Summary

Adds PubkyAppCollectionPointer — a single spec primitive that serves both Collections objectives:

  • List your own posted collections — sovereign, no Nexus dependency
  • Subscribe to other users' collections — Nexus-indexed for streams + notifications

One struct, one path pattern, one Resource variant, one PubkyAppObject variant, one parser arm.

/pub/pubky.app/collections/<owner_id>/<post_id>      body: { created_at: i64 }

(body matches PubkyAppFollow exactly)

Role inference

Role is determined at read time by comparing the path's <owner_id> to the URI host (homeserver user):

  • owner_id == homeserver_userown-pointer (sovereign index entry). Indexers do nothing — the homeserver state is enough to list your own collections via a prefix scan on /pub/pubky.app/collections/<self>/.
  • owner_id != homeserver_userfollow-pointer (subscription). The Nexus watcher (forthcoming PR) materializes this as a (:User)-[:FOLLOWS_COLLECTION]->(:Post {kind:'collection'}) edge and emits a follow-notification to the target owner.

The spec encodes no role field. Indexers and clients apply the same owner-equality check independently.

Why not reserved tag-label `"follow"`?

The original collections-plan.md proposed PubkyAppTag { uri: <collection_uri>, label: \"follow\" } for the follow side. Replaced by this design because:

  1. Reserving the English word "follow" as a tag label globally is a real user-facing cost in any language.
  2. Pubky's pattern for first-class relationships is first-class object kinds (follow, mute, bookmark, friend are all separate primitives — not tag conventions).
  3. The own-side has no clean tag-label equivalent — only a dedicated pointer delivers sovereign homeserver-side listing of your own collections.

Sovereign listing

  • All your collection touchpoints: prefix-scan /pub/pubky.app/collections/*/*
  • Just your own: prefix-scan /pub/pubky.app/collections/<you>/*
  • Just your follows: prefix-scan /pub/pubky.app/collections/*/* and filter on owner != you

@SHAcollision
SHAcollision force-pushed the feat/spec-v0.5.0-collection-pointer branch from bdf2edf to 142c2f8 Compare May 14, 2026 13:54
SHAcollision and others added 4 commits May 20, 2026 10:53
….5.0) (#116)

* Add `Collection` post kind + `PubkyAppCollectionContent` envelope (v0.5.0)

A collection post curates an ordered list of other posts (URIs in
`attachments`) under a `name` and optional `description`. On the wire it
is a `PubkyAppPost` with `kind = "collection"` and a typed JSON envelope
in `content`; no new homeserver path, no new top-level type.

Spec changes:
- New `PubkyAppPostKind::Collection` variant (placed before `Unknown` so
  the `#[serde(other)]` catch-all still works).
- `FromStr`, `Display`, and both WASM kind getters handle the variant.
- New `PubkyAppCollectionContent { name, description }` struct, parsed
  but never re-serialized as a top-level homeserver object. Forward-compat
  by design: NO `deny_unknown_fields`, so future additive fields (e.g.
  `cover_image`) won't break older parsers.
- Three new limits constants:
  - `collection_content_max_length = 2_000`
  - `collection_items_max_count    = 100`
  - `collection_item_uri_max_length = 300`
- Kind-gated `validate` early-branch for `Collection`: forbids parent
  and embed, parses the envelope, validates name (1..=100), description
  (..=500), attachment count (..=100), per-URI length (..=300), and
  protocol allowlist. Existing kind-switch tuple stays untouched for the
  six legacy kinds.
- Re-export `PubkyAppCollectionContent` from `lib.rs`.
- VERSION bumped to 0.5.0.

Tests: 17 native + 1 wasm-gated for the `Collection` variant
(round-trip, malformed envelope, name/description/items/URI boundaries
on both sides, parent/embed rejection, zero-item drafts allowed,
forward-compat tolerance of extra envelope fields, protocol allowlist,
existing-kinds regression). Phase-1 FromStr-rejects test updated to
drop `"collection"` from the unknown-strings list (it's now a known
kind).

cargo nextest run: 187 / 187 pass.

* Apply rustfmt to test module additions

* Promote Collection name/description bounds to `VALIDATION_LIMITS`

Code-review pass flagged that the `1..=100` (name) and `..=500`
(description) magic numbers in the Collection validation branch were
hardcoded inline rather than living in `VALIDATION_LIMITS` like the
other Collection limits (`collection_content_max_length`,
`collection_items_max_count`, `collection_item_uri_max_length`).

Drift risk: frontend / WASM consumers serialize `VALIDATION_LIMITS` for
client-side hints; with the bounds hardcoded here they can't see the
authoritative values. Promoting them keeps `VALIDATION_LIMITS` as the
single source of truth.

- Add three new fields:
    - `collection_name_min_length = 1`
    - `collection_name_max_length = 100`
    - `collection_description_max_length = 500`
- Replace inline literals in `validate` with field reads. Error messages
  now interpolate the values rather than baking them in (so a future
  bump only needs to touch `limits.rs`).
- Update doc-comments on `PubkyAppCollectionContent::{name,description}`
  to reference the constants instead of the literals.

`cargo nextest run`: 187/187 pass. `cargo fmt --check`: clean.

* Add PubkyAppPostKind::is_known() helper

Addresses PR #116 review comment. Provides a small inherent helper so
consumers — indexers, stream filters, search ranking — can write
`if kind.is_known() { ... }` rather than
`if !matches!(kind, PubkyAppPostKind::Unknown) { ... }`.

No WASM binding: JS consumers already receive `kind` as a string via
the existing WASM getter and can check `kind !== "unknown"` directly.

* Drop commented-out kind() getter scaffolds

Two identical dead-code blocks under PubkyAppPostEmbed::kind() and
PubkyAppPost::kind() that previewed a `kind() -> PubkyAppPostKind`
alternative incompatible with WASM. No callers, no explanation.

* Cover Collection envelope edge cases and dangerous protocols

Five tests:
- accepts_empty_description: description = "" is valid (0..=500)
- accepts_max_description: description = "a".repeat(500) is valid
- rejects_missing_name: envelope JSON with no `name` key is rejected
- rejects_javascript_protocol: attachment "javascript:..." rejected
- rejects_data_uri_protocol: attachment "data:..." rejected

The two protocol tests are XSS-vector defenses — `javascript:` and
`data:` parse as URLs but must not pass the attachment-protocol
allowlist alongside `pubky`/`http`/`https`.

* Clarify PubkyAppCollectionContent intent, mark pubky-sdk URI gap, align protocol error

Three polish changes for PR #116:

- Add a Construction paragraph to PubkyAppCollectionContent's doc-comment
  explaining that the struct is a deserialization target (parsed from a
  Post's content envelope) and is not intended to be constructed by
  callers directly. The pub re-export from lib.rs exists so SDK consumers
  can inspect the envelope shape (OpenAPI schema, type definitions).

- Mark a TODO at the attachment-URL parse site noting that the shortened
  pubky-sdk absolute URI form `pubky<pubkey>/pub/...` (no `://`) is a
  valid pubky resource per pubky-core / pubky-sdk but is currently
  rejected by `Url::parse`. This rejection is systemic across the crate
  (tag.rs, bookmark.rs, user.rs, file.rs, common.rs, uri_parser.rs); a
  follow-up issue should add a normalization shim once Collections lands.

- Align the Collection attachment-protocol rejection error with the
  legacy attachment-protocol error at :445-460: enumerate the allowed
  protocols in the message, include the offending attachment's index,
  and use the same `must use one of the allowed protocols` phrasing.
  This makes the two error sites consistent for end users.

* Apply rustfmt to Collection envelope/protocol tests

* Add WASM ergonomic helper: PubkySpecsBuilder.createCollectionPost()

JS callers shouldn't have to JSON.stringify the Collection envelope
themselves before passing it as `content` to createPost. This helper
takes the structured envelope fields directly:

    createCollectionPost(name, description?, attachments?) → PostResult

and builds the {name, description} envelope internally before
delegating to PubkyAppPost::new(..., Collection, ...). parent and
embed are omitted from the signature since the validator rejects
them for Collection posts.

Adds a wasm_bindgen_test exercising the full path (builder
construction → envelope serialization → typed PostResult →
envelope round-trip via serde_json) so the TS contract has a
runnable test.

* Address PR #116 review feedback

- Apply ok300's docblock fix for PubkyAppCollectionContent (ordered list
  of URIs, not posts).
- Use the new is_known() helper at the two `validate()` sites that
  reject Unknown kinds (self.kind and embed.kind), per aintnostressin.
- Drop the .trim() from Collection-name length validation per ok300:
  leading/trailing whitespace now counts toward the total. Add an
  explicit whitespace-only-name guard so "    " is still rejected, and
  update limits.rs docstrings to match. Two new tests cover both the
  whitespace rejection and the load-bearing assertion that padded names
  now count their full length toward max.
- Replace the verbose pubky-sdk-URI TODO with a brief note; tracking
  the systemic shim as a separate issue.

* Move Collection items into envelope; reserve post.attachments

Per aintnostressin's review: Collection items and post attachments are
distinct concepts. Conflating them in `post.attachments` left no room
for future Collection-level attachments (cover image, metadata).

Change: `PubkyAppCollectionContent` gains `items: Vec<String>` (with
#[serde(default)] for forward-compat). The Collection validator now
validates `envelope.items` and rejects any non-empty `post.attachments`
on a Collection — anti-misuse guard that lifts when Collections gain
real attachments later. WASM `createCollectionPost` builder routes its
third argument into the envelope; same signature shape, renamed
`attachments → items` for clarity.

Raises `collection_content_max_length: 2_000 → 40_000` (scalar budget;
the envelope now holds the items list). Updates tests accordingly,
adds 3 regression tests covering the anti-misuse guard, missing-items
forward-compat, and the load-bearing max-size envelope.

* Revise rustdoc for WASM's createCollectionPost

Update documentation for createCollectionPost function to reflect changes in parameters and functionality.

---------

Co-authored-by: ok300 <106775972+ok300@users.noreply.github.com>
Introduces a single spec primitive that serves both Collections
objectives — sovereign homeserver-side listing of your own collections
AND subscription pointers to other users' collections — via one path
shape:

    /pub/pubky.app/collections/<owner_id>/<post_id>

with body { created_at: i64 } matching PubkyAppFollow exactly. The
role of a given pointer is inferred at read time by comparing the
path's <owner_id> against the URI host (the homeserver user):

  - owner_id == homeserver_user → own-pointer (sovereign index entry).
    Indexers do nothing with these; the homeserver state suffices.
  - owner_id != homeserver_user → follow-pointer (subscription).
    Indexers (Nexus) materialize this as a :FOLLOWS_COLLECTION edge.

The spec encodes no role field, no role subfolder, no role variant.

Wiring:
  - new src/models/collection_pointer.rs with HasIdPath/Validatable
    impls, WASM bindings (fromJson/toJson/createPath), unit tests.
  - one PubkyAppObject::CollectionPointer variant + one from_resource
    dispatch arm.
  - one Resource::CollectionPointer { owner, post_id } variant +
    Display/id()/try_to_uri_str entries + one parser arm placed BEFORE
    the generic [res_type, id, ..] (with a guard that falls through
    cleanly for non-collections three-segment paths).
  - lib.rs re-export + utils.rs URI builder.

Three new parser tests cover the happy path (round-trip via
try_to_uri_str), the trailing-slash → Unknown fallthrough, and the
malformed-owner-pubkey rejection.
The CollectionPointer primitive carries no role field. Whether a given
pointer is an own-pointer (sovereign index entry for the user's own
collection) or a follow-pointer (subscription to someone else's
collection) is determined at read time by comparing the URI host
(ParsedUri::user_id) against the path-encoded `owner`.

These two PubkyAppObject::from_uri integration tests encode that
convention as part of the spec's testable contract:

- test_import_collection_pointer_own_role: when the URI host matches the
  path owner, the parsed Resource carries owner == user_id (consumers
  treat this as an own-pointer; indexers must skip notification +
  graph-edge creation).

- test_import_collection_pointer_follow_role: when the URI host differs
  from the path owner, the parsed Resource carries owner != user_id
  (consumers treat this as a follow-pointer; indexers materialize the
  :FOLLOWS_COLLECTION edge and fire a notification).

Both produce the same PubkyAppObject::CollectionPointer variant; the
test is at the Resource layer where the convention is observable.
@SHAcollision
SHAcollision force-pushed the feat/spec-v0.5.0-collection-pointer branch from 57c385e to 634a7ae Compare May 20, 2026 09:47
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.

1 participant