Skip to content

Extending qnop

qnop-spi is the seam between the community edition and anything built on top of it, commercial add-ons included. It is published to GitHub Packages as io.qnop:qnop-spi under AGPL-3.0 and has no runtime dependencies — not even Spring — so an implementation is a plain library.

The surface is deliberately small. Seven contracts ship today, in two shapes:

  • Implemented by you, called by qnop — StorageProvider, DocumentExtractor, MentionResolver, PublishedEventListener and BearerCredentialAuthenticator. Each has a community default, or none at all where the default is “nobody listens”.
  • Implemented by qnop, called by you — ExternalParticipants and ReviewFacade. These hand an extension the core’s own rules instead of letting it re-derive them from the database.

io.qnop.spi.storage.StorageProvider is where document binaries go.

public interface StorageProvider {
void put(String key, InputStream content, long contentLength, String contentType);
Optional<StorageContent> get(String key);
boolean exists(String key);
boolean delete(String key);
default Stream<StorageListing> list(String prefix) {
throw new UnsupportedOperationException();
}
}

StorageContent is an AutoCloseable record of stream, length and content type; StorageListing reports key, size and last-modified. Failures surface as the unchecked StorageException.

list is optional because not every backing store can enumerate cheaply. The storage-consistency scan uses it when available.

The community edition ships an S3-compatible implementation registered so that any StorageProvider bean you supply replaces it. Providing your own is therefore a matter of putting one on the classpath.

io.qnop.spi.extract.DocumentExtractor turns an uploaded file into the geometry the annotation layer anchors against.

public interface DocumentExtractor {
boolean supports(String contentType);
RenderedDocument extract(InputStream content) throws ExtractionException;
default Set<String> mediaTypes() { return Set.of(); }
}

The returned model is what makes line-precise anchoring possible:

TypeCarries
RenderedDocumentone or more Surfaces — pages
Surfaceindex, width, height, and its text spans
TextSpanthe text, its start and end offsets, its box, and per-character advances
NormalizedBoxx, y, width, height, all 0..1, origin top-left

Coordinates are normalised rather than absolute, so an annotation survives being rendered at a different scale. Per-character advances are what let a selection land between two characters instead of on a whole span.

supports is asked about the server-sniffed content type, never the file name. mediaTypes() is the enumerable side of the same claim: a type listed there passes the upload gate, shows up in GET /api/v1/config as a supported format, and the upload dialog’s file filter follows — so a new format needs no change anywhere else. Keep the two answers in agreement.

The community edition ships a PDF extractor. DOCX does not get an extractor of its own: it is converted to PDF out-of-process before this seam and then extracted as a PDF.

io.qnop.spi.mention.MentionResolver decides who an @slug in a comment reaches.

public interface MentionResolver {
Set<UUID> resolve(MentionContext context, String slug);
}

The returned ids are always user ids. A resolver for another namespace — a team, say — expands it into its members; the team’s own id never leaves the resolver. An empty set means “not mine”. Every registered resolver sees every token and the answers are unioned; the built-in one resolves profile slugs.

The core still has the last word: document access is checked for every resolved user, so a resolver can narrow who may be mentioned but never widen it, and an anonymized review is protected before any resolver is called.

io.qnop.spi.event.PublishedEventListener receives a curated stream of review events.

public interface PublishedEventListener {
void on(PublishedEvent event);
}

A PublishedEvent carries a catalogued type (see PublishedEventTypes), a timestamp, the document id, the actor id and an identifier-only attribute map — never titles, bodies or other customer content. Listeners are called after the transaction commits, off the request thread, each isolated from the others. Delivery is best-effort and in-process, with no ordering guarantee: retries, durability and signing are the consumer’s job. In an anonymized review no identity crosses the seam at all.

io.qnop.spi.auth.BearerCredentialAuthenticator lets a service call the existing API with a credential that is not a user’s token.

public interface BearerCredentialAuthenticator {
Optional<MachinePrincipal> authenticate(String bearerCredential);
}

It is consulted only after qnop’s own token check has rejected the credential, so rate limiting, CSRF and the 401 response stay the core’s. A MachinePrincipal’s subject must not be a UUID, and its scopes surface as EXT_-prefixed authorities — never as roles — so a machine principal cannot pass for a user or an administrator.

Two contracts run the other way: qnop implements them, an extension injects them.

  • ExternalParticipants adds account-less participants — a guest with a display name — to a review, removes them, and answers whether they have access. Validation, auditing and anonymity stay with the core; links, credentials and expiry are the extension’s business.
  • ReviewFacade answers who may view a review, who belongs to its notification circle, and how an author is shown to a given viewer. It shares its implementation with qnop’s own mail path, so an extension cannot drift from what qnop itself enforces.

Today the reliable way to extend qnop is a JVM implementation of the contracts above, on the classpath of your own build. A full reference of every extension point — including the in-process UI slots that will feed the frontend loader — lives in the repository’s docs/EXTENSION-POINTS.md.

qnop-spi is AGPL-3.0 like the rest of the community edition. The architecture decision that defines this seam also defines it as the licence boundary — which is what allows commercial add-ons to exist against a published contract instead of as a fork. The reasoning is public in the repository’s ADRs.