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,PublishedEventListenerandBearerCredentialAuthenticator. Each has a community default, or none at all where the default is “nobody listens”. - Implemented by qnop, called by you —
ExternalParticipantsandReviewFacade. These hand an extension the core’s own rules instead of letting it re-derive them from the database.
Storage providers
Section titled “Storage providers”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.
Document extractors
Section titled “Document extractors”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:
| Type | Carries |
|---|---|
RenderedDocument | one or more Surfaces — pages |
Surface | index, width, height, and its text spans |
TextSpan | the text, its start and end offsets, its box, and per-character advances |
NormalizedBox | x, 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.
Mentions
Section titled “Mentions”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.
Review events
Section titled “Review events”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.
Machine credentials
Section titled “Machine credentials”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.
Calling into the core
Section titled “Calling into the core”Two contracts run the other way: qnop implements them, an extension injects them.
ExternalParticipantsadds 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.ReviewFacadeanswers 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.
What is not there yet
Section titled “What is not there yet”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.
Licensing
Section titled “Licensing”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.