A Node.js/Express implementation of the C2PA Soft Binding Resolution API v2.4. This server allows clients to store C2PA Manifest Stores, associate them with soft binding values (watermarks or fingerprints), and later recover those manifests when the metadata has been stripped from an asset.
C2PA (Coalition for Content Provenance and Authenticity) embeds provenance metadata — called a Manifest Store — directly inside media files. However, many distribution platforms strip this metadata during upload or processing.
Soft binding is a resilience mechanism: an invisible watermark or content fingerprint is embedded in the asset's pixels/audio/video signal itself. Because the signal is part of the content, it survives platform processing that would remove metadata. The watermark or fingerprint acts as a lookup key that can be used to retrieve the original Manifest Store from a repository — restoring the provenance chain even after the embedded metadata is gone.
Consumer detects a watermark in an image
│
▼
POST /matches/byBinding ← sends the watermark value
│
▼
Server returns manifest ID(s)
│
▼
GET /manifests/{manifestId} ← fetches the full Manifest Store
│
▼
Consumer validates provenance and makes a trust decision
| Scenario | Without soft binding | With soft binding |
|---|---|---|
| Social platform strips metadata | Provenance lost | Manifest recovered via watermark |
| Malicious manifest substitution | Cannot detect | Compare embedded vs. recovered manifest |
| Legacy pipeline strips metadata | Gap in provenance chain | Watermark bridges the gap |
| AI-generated content | GenAI label lost | Label recovered from repository |
The server implements all four route groups defined in the specification, mounted under /v1.
| Method | Route | Description |
|---|---|---|
GET |
/v1/matches/byBinding |
Look up manifests by a pre-computed binding value in the query string |
POST |
/v1/matches/byBinding |
Same, but accepts a large binding value in a JSON body |
POST |
/v1/matches/byContent |
Upload a raw asset; the server extracts the soft binding and searches |
POST |
/v1/matches/byReference |
Provide an HTTPS URL; the server downloads the asset and searches |
| Method | Route | Description |
|---|---|---|
POST |
/v1/manifests |
Ingest a C2PA Manifest Store; returns the manifest ID and optionally a receipt |
POST |
/v1/bindings |
Associate a soft binding value with a stored manifest |
PUT |
/v1/bindings |
Update the manifest an existing binding points to |
DELETE |
/v1/manifests/:manifestId |
Remove a manifest and its associated bindings |
| Method | Route | Description |
|---|---|---|
GET |
/v1/manifests/:manifestId |
Retrieve a stored C2PA Manifest Store by ID |
GET |
/v1/manifests/:manifestId/receipts |
Retrieve the repository receipt for a manifest |
POST |
/v1/manifests/:manifestId/receipts |
Verify a caller-supplied receipt against a stored manifest |
| Method | Route | Description |
|---|---|---|
GET |
/v1/services/supportedAlgorithms |
List the watermark and fingerprint algorithms this instance supports |
GET |
/v1/services/capabilities |
List which optional endpoints this instance implements |
GET |
/v1/services/status |
Report current operational status (ok / degraded / down) |
GET |
/.well-known/c2pa-soft-binding-resolution |
Discovery document — served at the domain root, not under /v1
|
All four are public — no auth is required. See Server status and capability reporting below.
c2pa-soft-binding-server/
├── package.json # npm workspaces root — published as @cognitiveproof/softbinding-api-server
├── openapi.yaml # OpenAPI spec served at /v1/openapi.json and /docs
├── .env.example
├── src/
│ ├── index.ts # Library entry point — exports createServer() and shared types
│ ├── cli.ts # Standalone entry point (bin script)
│ ├── config.ts # SoftBindingServerOptions type + resolveConfig() (options with env-var fallbacks)
│ ├── store.ts # loadDataStore() — resolves a DataStorePlugin instance or package name
│ ├── objectStore.ts # loadObjectStore() — resolves an ObjectStorePlugin instance or package name
│ ├── auth.ts # resolveAuthMiddleware() — pluggable JWT/custom auth, defaults to allow-all + a warning
│ ├── logger.ts # resolveLogger() — pluggable structured logging, defaults to a console JSON logger
│ ├── rateLimit.ts # resolveRateLimitStore() — pluggable rate limit store, defaults to in-memory
│ ├── softBinding.ts # createSoftBindingRegistry(extractors) — soft binding extractor registry
│ ├── utils/
│ │ └── ssrf.ts # SSRF protection for the byReference endpoint
│ └── routes/
│ ├── query.ts # createQueryRouter() — GET|POST /matches/* routes
│ ├── store.ts # createStoreRouter() — POST|PUT /bindings, POST|DELETE /manifests routes
│ ├── fetch.ts # createFetchRouter() — GET /manifests/:id and receipt routes
│ └── service.ts # createServiceRouter() — GET /services/supportedAlgorithms|capabilities|status
├── plugins/ # Storage and auth plugins — each is its own npm package
│ ├── types/ # @cognitiveproof/softbinding-api-plugin-types — shared interfaces
│ ├── sqlite/ # @cognitiveproof/softbinding-api-plugin-sqlite — DataStore plugin (default)
│ ├── mongoDB/ # @cognitiveproof/softbinding-api-plugin-mongodb — DataStore plugin
│ ├── postgres/ # @cognitiveproof/softbinding-api-plugin-postgres — DataStore plugin
│ ├── mysql/ # @cognitiveproof/softbinding-api-plugin-mysql — DataStore plugin
│ ├── gcpBucket/ # @cognitiveproof/softbinding-api-plugin-gcp-bucket — ObjectStore plugin
│ ├── awsBucket/ # @cognitiveproof/softbinding-api-plugin-aws-bucket — ObjectStore plugin
│ ├── google-auth/ # @cognitiveproof/softbinding-api-plugin-google-auth — AuthPlugin (opt-in)
│ ├── pino-logger/ # @cognitiveproof/softbinding-api-plugin-pino-logger — LoggerPlugin
│ ├── redis-rate-limit/ # @cognitiveproof/softbinding-api-plugin-redis-rate-limit — RateLimitStorePlugin
│ └── vsmark/ # @cognitiveproof/softbinding-api-plugin-vsmark — Extractor (text watermark)
└── conformance/ # @cognitiveproof/softbinding-api-conformance — black-box spec-conformance test harness (see below)
Exports createServer(options?), which resolves configuration, loads the data store plugin, builds the soft binding registry and auth middleware, and wires everything into an express.Express app: helmet() security headers (unless helmet: false), request logging, global JSON body parser, /docs + /v1/openapi.json (unless docs: false), rate limiting on /v1 (unless rateLimit: false), the four route routers under /v1, and a 404 + error handler. The returned app is not listening — embed it in another app or call .listen() yourself.
The bin script. Loads .env, calls createServer() with no overrides (so everything falls back to environment variables), and calls app.listen(PORT).
Defines SoftBindingServerOptions (the createServer() options type) and resolveConfig(), which merges each option with its environment-variable fallback (e.g. options.repoUri ?? process.env.REPO_URI ?? 'http://localhost:3000'). If RECEIPT_SECRET/receiptSecret is absent a random secret is generated at startup (receipts will not survive a restart — set a real secret in production). Authentication config (auth) is resolved separately by resolveAuthMiddleware() in src/auth.ts — see below.
loadDataStore(plugin?) and loadObjectStore(plugin?) resolve a DataStorePlugin / ObjectStorePlugin: pass an instance directly, pass an installed npm package name to require(), or omit it to fall back to DATASTORE_PLUGIN/OBJECTSTORE_PLUGIN and then the default SQLite/GCS package names (see Storage, Auth, Logging, and Rate Limit Plugins below). The SQLite plugin is a real dependency of this package (writes to ./data/softbinding.sqlite with zero configuration); every other plugin package is a separate install. createServer() calls loadDataStore() and injects the result into the route factories — routes have no direct knowledge of how or where data is persisted. loadObjectStore() is exported for custom routes but isn't wired into createServer() since no bundled route uses blob storage yet.
resolveAuthMiddleware(auth, logger) resolves the auth middleware that createServer() applies to all /v1 routes, based on the auth option:
-
authis an ExpressRequestHandler— used as-is. Bring your own auth scheme entirely (API keys, sessions, a different OAuth provider, etc.). -
authis{ issuer, audience, jwksUri }— builds a generic JWT-verification middleware viacreateJwtAuthMiddleware()for any OIDC-compatible identity provider (Google Identity Platform, Auth0, Okta, Cognito, etc.). -
authis omitted — no authentication is performed at all: every request to/v1routes is treated as fully authorized. A one-time warning is logged at startup via the configuredloggerso this doesn't go unnoticed in production. This is meant for local development, not production — see Authentication below for how to wire in real auth, including the bundled Google Identity Platform plugin.
The JWT-based middleware fetches its JWKS once per server instance and caches/refreshes keys via jose, returning 401 if no header is present, the token is expired, or verification fails. It reads scopes from the standard space-delimited scope claim or the string/array scp claim and publishes them to res.locals.c2paAuthScopes.
The route layer returns 403 unless JWT-authenticated requests have the scope required by the specification: fetch:manifests for query/fetch routes, store:manifests for manifest ingestion/deletion, and store:bindings for binding creation/update. An AuthPlugin (e.g. the bundled Google plugin) requires these values to be configured as custom token claims. A fully custom auth middleware remains responsible for its own authorization; it can opt into the same route guards by setting res.locals.c2paAuthScopes to a string array. When auth is omitted entirely, no scopes are set and every route guard passes unconditionally — consistent with "no auth configured means everyone is authorized."
resolveLogger(logger) resolves the Logger that createServer() uses for request logging and error reporting, based on the logger option:
-
loggeris aLoggerinstance — used as-is. Bring your own logger by implementingdebug/info/warn/error/child(e.g. wrapping an existing Winston/Bunyan instance). -
loggeris a package name, orLOGGER_PLUGINis set — loads an npm package implementingLoggerPlugin(e.g. the optional@cognitiveproof/softbinding-api-plugin-pino-logger). -
loggeris omitted andLOGGER_PLUGINis unset — falls back tocreateConsoleLogger(), a built-in logger that writes JSON lines (level,time,msg, plus any metadata) to stdout (debug/info/warn) or stderr (error) — no extra dependencies required.
createServer() mounts createRequestLogger(logger) as the first middleware, which logs { method, path, status, durationMs } for every request once the response finishes. The error handler returns 413 for bodies exceeding the configured JSON/raw upload limits, 400 for malformed JSON, and logs other uncaught route errors via logger.error() instead of console.error().
createServer() mounts helmet() as the first middleware, which sets a baseline of security-related HTTP response headers (X-Content-Type-Options, X-DNS-Prefetch-Control, Strict-Transport-Security, removes X-Powered-By, etc.). Pass helmet: false to disable it entirely, or helmet: { ...HelmetOptions } to customize (e.g. configure a Content-Security-Policy). Default: enabled with helmet's defaults.
createServer() mounts express-rate-limit on all /v1 routes (not /, /docs, or /v1/openapi.json), returning 429 Too Many Requests once a client exceeds the limit. Default: 100 requests per 15 minutes per IP; pass rateLimit: { ...RateLimitOptions } to customize (windowMs, limit, keyGenerator, etc.) or rateLimit: false to disable.
By default the limiter uses express-rate-limit's in-memory store, which is per-process — each server instance/replica tracks its own counters. resolveRateLimitStore(rateLimitStore) resolves a shared Store instead, given the rateLimitStore option:
-
rateLimitStoreis aStoreinstance — used as-is (e.g. constructed withrate-limit-redisdirectly). -
rateLimitStoreis a package name, orRATELIMIT_STORE_PLUGINis set — loads an npm package implementingRateLimitStorePlugin(e.g. the optional@cognitiveproof/softbinding-api-plugin-redis-rate-limit). -
rateLimitStoreis omitted andRATELIMIT_STORE_PLUGINis unset — uses express-rate-limit's default in-memory store.
Watermark detection and content fingerprinting are algorithm-specific and typically provided by specialised third-party libraries. createSoftBindingRegistry(extractors?) builds a per-server registry from the extractors option passed to createServer():
createServer({
extractors: {
'com.example.watermark.v1': async (buffer, mimeType) => base64StringOrNull,
},
});When POST /matches/byContent or POST /matches/byReference is called with an alg query parameter, the corresponding extractor is invoked on the asset buffer. If no extractor is registered for that algorithm the route returns an empty match list. getSupportedAlgorithms() reflects whatever algorithms were registered and drives the /services/supportedAlgorithms response.
createServiceRouter() (src/routes/service.ts) also serves two other public, unauthenticated endpoints, and createServer() mounts a third directly on the app (outside /v1, per RFC 8615):
-
GET /v1/services/capabilities— returns{ c2paSpecificationVersion, supportedCapabilities }, wheresupportedCapabilitiesis a static list of the optional endpoints this build implements (queryByContent,queryByReference,storeManifests,storeBindings). Intended for clients — including the conformance harness — to discover what an instance supports without guessing. -
GET /v1/services/status— returns{ status: 'ok' | 'degraded' | 'down', timestamp }. By default always reportsok; passgetServiceStatustocreateServer()to wire in a real health check (e.g. pinging the data store):createServer({ getServiceStatus: async () => { const healthy = await dataStore.ping(); return { status: healthy ? 'ok' : 'down' }; }, });
If
getServiceStatusthrows or rejects, the endpoint reports{ status: 'down' }rather than a 500 — the endpoint itself didn't fail, it's honestly reporting the failure it observed. -
GET /.well-known/c2pa-soft-binding-resolution— the spec's discovery document, returning{ apiEndpoint, c2paSpecificationVersion, capabilitiesEndpoint, statusEndpoint }. Served at the domain root (not/v1) so clients can find the API without already knowing the version prefix.
The byReference endpoint asks the server to fetch an asset from a caller-supplied URL, which is a Server-Side Request Forgery attack surface. Before downloading, this module:
- Rejects any non-HTTPS URL
- Resolves the hostname via DNS and rejects addresses in private/reserved ranges (RFC 1918, loopback, link-local, IPv6 ULA/link-local)
A production deployment should additionally use signed, short-lived URLs (as recommended by the spec) and enforce network-level egress controls to defeat DNS rebinding attacks.
Each route file exports a createXRouter(deps) factory that returns a plain Express Router, built from the data store, auth middleware, and config values createServer() passes in. Route handlers own input validation, call into dataStore/softBinding, and format responses. They contain no business logic beyond what is needed to translate between HTTP and the data layer.
Body parsing is applied per-route rather than globally:
- JSON routes use the global
express.json({ limit: maxJsonSize })applied increateServer() -
POST /manifestsaddsexpress.raw({ type: 'application/c2pa' }) -
POST /matches/byContentaddsexpress.raw()with a content-type guard that accepts anyimage/*,audio/*,video/*,application/*,model/*, ortext/*MIME type
When a manifest is ingested with ?returnReceipt=true, the server generates a receipt containing an HMAC-SHA256 proof over the receipt's repository.uri, repository.manifestId, and anchor.uri, signed with RECEIPT_SECRET. This proof can later be verified at GET /manifests/:id/receipts or by submitting the receipt to POST /manifests/:id/receipts — verification recomputes the HMAC over those fields (and checks anchor.proof.alg), so tampering with any of them, not just the manifest ID, invalidates the receipt.
The receipt structure follows the org.c2pa.manifest-receipt JSON-LD schema defined in the specification. In a production system the anchor.proof field would typically contain a cryptographic proof from an external transparency log or blockchain anchor rather than a local HMAC.
Run this project standalone (e.g. as a cloned repo) when you want a ready-to-go server controlled entirely by environment variables. If you want to embed the API into your own Express app or configure it programmatically, see Using as a Library.
- Node.js 18 or later
- npm
git clone <repo>
cd c2pa-soft-binding-server
npm install
cp .env.example .env
# Edit .env — at minimum set RECEIPT_SECRET. See "Authentication" below for
# how to require real auth instead of the allow-all default.npm start # production — runs dist/cli.js
npm run dev # development with auto-reload (requires nodemon)src/cli.ts calls createServer() with no overrides, so every option falls back to the environment variables below (see SoftBindingServerOptions for the corresponding option name):
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP port to listen on (CLI only — not a createServer() option) |
REPO_URI |
http://localhost:3000 |
Public base URI of this repository, used in receipts (repoUri) |
RECEIPT_SECRET |
random | Secret for signing receipts — must be set for receipts to survive restart (receiptSecret) |
MAX_UPLOAD_SIZE |
52428800 |
Max direct upload size in bytes (50 MB) (maxUploadSize) |
MAX_JSON_SIZE |
10485760 |
Max JSON request body size in bytes (10 MB) (maxJsonSize) |
MAX_REFERENCE_SIZE |
104857600 |
Max download size for byReference in bytes (100 MB) (maxReferenceSize) |
DATASTORE_PLUGIN |
@cognitiveproof/softbinding-api-plugin-sqlite |
npm package implementing DataStorePlugin (dataStore) — the SQLite plugin ships as a real dependency, so this works with no extra install |
OBJECTSTORE_PLUGIN |
@cognitiveproof/softbinding-api-plugin-gcp-bucket |
npm package implementing ObjectStorePlugin (objectStore) |
LOGGER_PLUGIN |
— | npm package implementing LoggerPlugin, used when logger is not set (default: built-in console JSON logger) |
RATELIMIT_STORE_PLUGIN |
— | npm package implementing RateLimitStorePlugin, used when rateLimitStore is not set (default: in-memory store) |
SKIP_ENV_VALIDATION |
— | Set to skip per-plugin env validation (e.g. database connection strings) — useful for tests |
There is no env var for auth — there's no default auth plugin to select anymore (see Authentication below). Pass auth to createServer() directly, or build an npm package name into your own wiring (e.g. require('@cognitiveproof/softbinding-api-plugin-google-auth').default(process.env.GCP_PROJECT_ID)).
Each storage plugin reads its own additional environment variables — see Storage, Auth, Logging, and Rate Limit Plugins.
Install the package and call createServer(options) to get a configured express.Express app — mount it inside an existing app, or call .listen() yourself:
npm install @cognitiveproof/softbinding-api-server \
@cognitiveproof/softbinding-api-plugin-google-auth@cognitiveproof/softbinding-api-plugin-sqlite doesn't need a separate install — it's a real dependency of the server package, so it's already there as the default data store.
import { createServer } from '@cognitiveproof/softbinding-api-server';
import createGoogleAuthMiddleware from '@cognitiveproof/softbinding-api-plugin-google-auth';
const c2paApp = createServer({
// auth is not automatic — build and pass the middleware yourself (see
// "Authentication" below). Omitting this entirely logs a startup warning
// and lets every request through, which is fine for local dev only.
auth: createGoogleAuthMiddleware('my-gcp-project'),
repoUri: 'https://repo.example.com',
receiptSecret: process.env.RECEIPT_SECRET,
// dataStore defaults to the bundled SQLite plugin (./data/softbinding.sqlite);
// omit this entirely unless you want a different backend or path:
dataStore: require('@cognitiveproof/softbinding-api-plugin-sqlite').default,
extractors: {
'com.example.watermark.v1': async (buffer, mimeType) => {
const id = await myWatermarkLib.detect(buffer);
return id ? Buffer.from(id).toString('base64') : null;
},
},
});
// Standalone:
c2paApp.listen(3000);
// Or mounted inside an existing app:
// app.use('/c2pa', c2paApp);SoftBindingServerOptions (all optional, each falls back to an environment variable — see the table above):
| Option | Type |
|---|---|
repoUri |
string |
receiptSecret |
string |
maxUploadSize / maxJsonSize / maxReferenceSize
|
number |
dataStore |
DataStorePlugin | string |
objectStore |
ObjectStorePlugin | string |
auth |
RequestHandler | { issuer, audience, jwksUri } — auth for /v1 routes; omitted means allow-all + a startup warning (see below) |
extractors |
Record<string, Extractor> |
parseManifestId |
(data: Buffer) => string | Promise<string> — derives manifestId from the uploaded manifest itself (see below) |
docs |
boolean (default true) — mounts /docs and /v1/openapi.json
|
logger |
Logger | string — overrides the default console JSON logger (see below) |
helmet |
HelmetOptions | false — customizes or disables helmet() security headers (default: enabled with helmet's defaults) |
rateLimit |
Partial<RateLimitOptions> | false — customizes or disables rate limiting on /v1 (default: 100 requests/15min per IP) |
rateLimitStore |
Store | string — shared rate limit store (e.g. Redis) for multi-instance deployments |
getServiceStatus |
() => { status } | Promise<{ status }> — health check backing GET /services/status (default: always ok) |
createServer() does not pick a default auth scheme for you. If auth is omitted, every request to /v1 routes is treated as fully authorized (no identity verification at all), and a warning is logged once at startup via the configured logger — this is meant for local development, not production. To require real auth, pass auth:
// The bundled Google Identity Platform plugin — install it yourself and
// call it with your GCP project id:
// npm install @cognitiveproof/softbinding-api-plugin-google-auth
import createGoogleAuthMiddleware from '@cognitiveproof/softbinding-api-plugin-google-auth';
createServer({
auth: createGoogleAuthMiddleware(process.env.GCP_PROJECT_ID!),
// ...
});
// Any other OIDC-compatible provider (Auth0, Okta, Cognito, ...):
createServer({
auth: {
issuer: 'https://my-tenant.auth0.com/',
audience: 'https://my-api/',
jwksUri: 'https://my-tenant.auth0.com/.well-known/jwks.json',
},
// ...
});
// Or a fully custom Express middleware:
createServer({
auth: (req, res, next) => {
if (req.headers['x-api-key'] === process.env.API_KEY) {
res.locals.c2paAuthScopes = ['fetch:manifests', 'store:manifests', 'store:bindings'];
return next();
}
res.status(401).json({ error: 'Unauthorized' });
},
// ...
});By default, logs are written as JSON lines to stdout/stderr. To use a different logger, pass logger:
// A LoggerPlugin package, e.g. the optional pino plugin:
createServer({
logger: '@cognitiveproof/softbinding-api-plugin-pino-logger',
// ...
});
// Or a Logger instance (bring your own — wrap Winston, Bunyan, pino, etc.):
createServer({
logger: {
debug: (msg, meta) => myLogger.debug(meta, msg),
info: (msg, meta) => myLogger.info(meta, msg),
warn: (msg, meta) => myLogger.warn(meta, msg),
error: (msg, meta) => myLogger.error(meta, msg),
child: (bindings) => wrapLogger(myLogger.child(bindings)),
},
// ...
});By default, /v1 routes are limited to 100 requests/15min per IP using an in-memory store. To customize the limit or share counters across instances via Redis, pass rateLimit/rateLimitStore:
createServer({
rateLimit: { windowMs: 60_000, limit: 30 }, // 30 requests/minute per IP
rateLimitStore: '@cognitiveproof/softbinding-api-plugin-redis-rate-limit',
// ...
});
// Or disable rate limiting entirely (e.g. if handled by a gateway/load balancer):
createServer({
rateLimit: false,
// ...
});The package also re-exports the DataStorePlugin, ObjectStorePlugin, AuthPlugin, Logger, LoggerPlugin, RateLimitOptions, RateLimitStore, RateLimitStorePlugin, Match, ManifestEntry, Receipt, LoadedData, Extractor, AuthScope, and JwtAuthOptions types from @cognitiveproof/softbinding-api-plugin-types/src/auth.ts, plus loadDataStore, loadObjectStore, createJwtAuthMiddleware, requireAuthScope, AUTH_SCOPES_LOCALS_KEY, and createConsoleLogger helpers.
Persistence, authentication, logging, and rate limit storage are implemented entirely by npm packages that conform to the interfaces in @cognitiveproof/softbinding-api-plugin-types:
-
DataStorePlugin— stores C2PA Manifest Stores, soft binding associations, and receipts (addManifest,getManifest,findByBinding,createBinding, etc.) -
ObjectStorePlugin— stores arbitrary binary blobs in a "data" bucket and a "public" bucket (saveData,loadData,getPublicUrl, etc.) -
AuthPlugin<TConfig>— builds the Express middleware used to authenticate/v1requests, given a config value (gcpProjectIdfor the Google plugin). Unlike the other plugin kinds, there's no default and no env-var-based auto-load: you install the package, call it yourself (e.g.createGoogleAuthMiddleware(gcpProjectId)), and pass the result asauth -
LoggerPlugin<TConfig>— builds theLoggerused for request logging and error reporting whenloggerisn't passed tocreateServer(), given an implementation-specific config value (e.g. a log level) -
RateLimitStorePlugin<TConfig>— builds the express-rate-limitStoreused to share rate limit counters across instances whenrateLimitStoreisn't passed tocreateServer(), given a config value (e.g. a Redis URL)
createServer() resolves the data store plugin via loadDataStore() (an instance passed as options.dataStore, an npm package name, or DATASTORE_PLUGIN/the default SQLite package name), the logger via resolveLogger() (options.logger, LOGGER_PLUGIN, or the built-in console logger), and the rate limit store via resolveRateLimitStore() (options.rateLimitStore, RATELIMIT_STORE_PLUGIN, or express-rate-limit's in-memory store), injecting the results into the route factories. Auth is the one exception — there's no AUTH_PLUGIN/default package lookup; options.auth is used as-is, or the server falls back to allow-all with a startup warning (see Authentication). Plugin packages must be installed separately. Route code never imports a plugin directly.
| Package | Implements | Backend | Required env vars |
|---|---|---|---|
@cognitiveproof/softbinding-api-plugin-sqlite |
DataStorePlugin |
SQLite (file or in-memory) — default, bundled | none — SQLITE_DB_PATH optional (default ./data/softbinding.sqlite) |
@cognitiveproof/softbinding-api-plugin-mongodb |
DataStorePlugin |
MongoDB | MONGO_DB_URI |
@cognitiveproof/softbinding-api-plugin-postgres |
DataStorePlugin |
PostgreSQL | POSTGRES_URL |
@cognitiveproof/softbinding-api-plugin-mysql |
DataStorePlugin |
MySQL | MYSQL_URL |
@cognitiveproof/softbinding-api-plugin-gcp-bucket |
ObjectStorePlugin |
Google Cloud Storage |
DATA_BUCKET_NAME, PUBLIC_BUCKET_NAME, optional GOOGLE_BUCKET_CREDENTIAL
|
@cognitiveproof/softbinding-api-plugin-aws-bucket |
ObjectStorePlugin |
AWS S3 (or S3-compatible, e.g. MinIO/R2) |
DATA_BUCKET_NAME, PUBLIC_BUCKET_NAME, optional AWS_REGION, AWS_S3_ENDPOINT, AWS_S3_FORCE_PATH_STYLE
|
@cognitiveproof/softbinding-api-plugin-google-auth |
AuthPlugin<string> |
Google Cloud Identity Platform | none — pass the GCP project id directly, e.g. createGoogleAuthMiddleware(process.env.GCP_PROJECT_ID)
|
@cognitiveproof/softbinding-api-plugin-pino-logger |
LoggerPlugin |
pino | optional LOG_LEVEL (default info) |
@cognitiveproof/softbinding-api-plugin-redis-rate-limit |
RateLimitStorePlugin |
Redis (via rate-limit-redis/ioredis) |
optional REDIS_URL (default redis://localhost:6379) |
@cognitiveproof/softbinding-api-plugin-vsmark |
Extractor |
Unicode variation-selector text watermark | none |
The SQL plugins (postgres, mysql, sqlite) all use the same manifests / bindings schema with a foreign key from bindings.manifest_id to manifests.id (ON DELETE CASCADE), and create their tables automatically on first use.
These live in this repo as npm workspaces under plugins/, but are ordinary npm packages — they can be published and installed independently.
@cognitiveproof/softbinding-api-plugin-sqlite is the one exception to the rule below — it's a real (non-peer) dependency of @cognitiveproof/softbinding-api-server, so npm install @cognitiveproof/softbinding-api-server alone gets you a working data store with zero extra installs or configuration.
Every other bundled plugin is published as a separate package and declared as an optional peer dependency instead. Installing the server does not install every database driver and cloud SDK — install only the plugin packages your deployment actually uses, e.g. to switch to Postgres:
npm install @cognitiveproof/softbinding-api-server @cognitiveproof/softbinding-api-plugin-postgrescreateServer({
dataStore: '@cognitiveproof/softbinding-api-plugin-postgres', // or pass the imported plugin directly
});If loadDataStore/loadObjectStore/the logger plugin loader/the rate limit store plugin loader can't find the requested plugin package, createServer() throws an error telling you which package to npm install. The pino logger and Redis rate-limit plugins are required only when selected explicitly. The server has built-in console logging and in-memory rate limiting.
Note on the standalone CLI (src/cli.ts): it calls createServer() with no overrides at all, so it has no way to pass auth. Running the published softbinding-api-server binary (or npm start/npm run dev in this repo) as-is always gets the allow-all-with-a-warning default described in Authentication — there's no environment variable that wires in real auth anymore. If you need the CLI's convenience (.env config, PORT, the route listing on startup) with real auth, copy src/cli.ts and add an auth line, or run createServer({ auth: ... }) yourself as a library instead.
To switch away from the default SQLite data store, install one of the provided PostgreSQL, MySQL, or MongoDB packages (or a GCS/S3 package for object storage) and point the relevant option or environment variable at its package name:
npm install @cognitiveproof/softbinding-api-plugin-postgresDATASTORE_PLUGIN=@cognitiveproof/softbinding-api-plugin-postgresThe plugin is loaded at runtime without server code changes. Implement a custom DataStorePlugin or ObjectStorePlugin only when the provided backends do not cover the deployment.
Custom plugin packages must export a default object implementing the corresponding interface:
import type { DataStorePlugin } from '@cognitiveproof/softbinding-api-plugin-types';
const plugin: DataStorePlugin = {
async addManifest(data, contentType) {
/* ... */
},
async getManifest(manifestId) {
/* ... */
},
// ...
};
export default plugin;Pass extractors to createServer({ extractors }) (see Using as a Library). Each extractor receives the asset as a Buffer and its MIME type, and must return a base64-encoded soft binding value or null if no binding is detected.
createServer({
extractors: {
'com.example.watermark.v1': async (buffer, mimeType) => {
const id = await myWatermarkLib.detect(buffer);
return id ? Buffer.from(id).toString('base64') : null;
},
},
});Algorithm names must match entries in the C2PA Soft Binding Algorithm List. Once registered the algorithm appears in GET /services/supportedAlgorithms and can be used with the alg parameter on the byContent and byReference routes.
@cognitiveproof/softbinding-api-plugin-vsmark is a ready-to-use Extractor for plain-text/HTML assets. It hides a soft binding value inside a piece of text by appending an invisible Unicode variation selector after each character, encoding the binding value byte-by-byte. The carrier text displays unchanged (the variation selectors are invisible to renderers) but a watermarked copy can be traced back to the original binding.
npm install @cognitiveproof/softbinding-api-plugin-vsmarkimport { createServer } from '@cognitiveproof/softbinding-api-server';
import {
vsmarkExtractor,
VSMARK_ALGORITHM,
encode,
} from '@cognitiveproof/softbinding-api-plugin-vsmark';
createServer({
extractors: { [VSMARK_ALGORITHM]: vsmarkExtractor },
});
// When publishing an asset, embed the manifest's binding value into its text:
const watermarked = encode(bindingValue, articleText);vsmarkExtractor decodes the asset buffer as UTF-8 and returns the hidden binding value, or null if the asset isn't text or contains no watermark. decode(text) is also exported directly for use outside of createServer().
By default, POST /manifests assigns each stored manifest a random urn:c2pa:<uuid> id — this repo has no CBOR/JUMBF parsing capability of its own, so it can't read the active manifest's real embedded label out of the uploaded bytes. Per spec, manifestId is supposed to be that real label, not a server-invented one. Pass parseManifestId to createServer() to supply your own parser.
c2pa-rs-javascript-library (Rust/WASM C2PA bindings) is one option — its verifyManifestBytes() is built specifically to verify a standalone C2PA Manifest Store with no host asset (unlike verifyAsset()/verifyAssetFromSidecar(), which both require the original asset bytes), which matches exactly what POST /manifests receives:
import { verifyManifestBytes } from 'c2pa-rs-javascript-library';
createServer({
parseManifestId: async (data) => {
// Pass [] to skip trust-chain validation and only parse structure, or a
// list of trusted certificate PEMs to also enforce signature trust.
const result = await verifyManifestBytes(data, []);
const activeManifest = result.manifestStore?.activeManifest;
if (!activeManifest) {
throw new Error('No active manifest found in this C2PA Manifest Store');
}
return activeManifest; // e.g. "urn:c2pa:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"
},
});Buffer (Node) is a Uint8Array, so data can be passed to verifyManifestBytes as-is. Any other C2PA-aware parser works the same way — parseManifestId just needs to return the active manifest's label as a string.
- If the parser throws or rejects,
POST /manifestsreturns400. - If it returns an id that's already stored with identical bytes, the request succeeds idempotently (
200, the existing id, no duplicate row) — a repeat upload of the same manifest. - If it returns an id that's already stored with different bytes, the request is rejected with
400as a label conflict, rather than silently overwritten — per the C2PA Technical Specification, the same label should never legitimately refer to different manifest content (the spec's own re-labeling mechanism exists precisely to avoid that). - If omitted, behavior is unchanged from today — the configured
DataStorePlugingenerates its own random id — butcreateServer()logs a one-time warning at startup (via the configuredlogger) explaining that stored manifest ids won't match the spec. This is a heads-up, not an error: the server still runs normally.
Implementing a custom DataStorePlugin? Its addManifest(data, contentType, manifestId?) should store the manifest under the supplied manifestId when present, falling back to generating its own only when it's omitted — see the bundled plugins (e.g. plugins/sqlite/src/index.ts) for the pattern.
Integration test: src/__tests__/integration/parseManifestId.c2paRs.test.ts exercises this exact wiring against the real c2pa-rs-javascript-library — it signs a real C2PA manifest and confirms the id our server stores it under matches exactly what the library itself reports as the active manifest, plus the idempotent-reupload behavior. It's a separate, heavier suite (real WASM signing/verification) kept out of the default npm test run — see jest.integration.config.js — and runs via:
npm run test:integration@cognitiveproof/softbinding-api-conformance is a black-box test harness for verifying that a server — this one, or a third party's independent implementation — actually behaves the way the spec requires. Unlike this repo's own Jest/Supertest suite (which imports the Express app directly and manipulates an in-memory fake data store), the conformance harness only ever speaks real HTTP to a baseUrl, exactly like an external client would. That's what makes it usable to check compatibility of any implementation, not just this one.
npx @cognitiveproof/softbinding-api-conformance \
--base-url https://staging.example.com/v1 \
--token <bearer-token>It calls GET /v1/services/capabilities first to discover which optional endpoints the target implements, and skips suites it can't exercise rather than failing them — only one query endpoint plus GET /manifests/{id} are actually mandatory per spec. What it checks today: the discovery endpoints, the manifest store → fetch → delete round trip, auth enforcement on store endpoints, bindings, and the receipts round trip. Optional query endpoints (byContent, byReference) and scope-granularity testing are not covered yet.
⚠ This creates and deletes real data on the target — point it at a sandbox/staging deployment, not production. See conformance/README.md for the full flag reference, exactly what gets checked, and how to debug a failed run (--no-cleanup leaves fixtures in place, each tagged with a softbinding-conformance-fixture:<uuid> marker).
| Concern | Current approach | Production recommendation |
|---|---|---|
| Persistence | MongoDB, PostgreSQL, MySQL, and SQLite data store plugins | Select the backend appropriate for durability, backups, scaling, and operational ownership |
| Object storage | Google Cloud Storage and S3-compatible object store plugins | Configure bucket lifecycle, encryption, access controls, and regional availability |
| Authentication | Google Identity Platform or generic OIDC JWT verification | Use OAuth2 client credentials, issue required scopes, and rotate provider credentials |
| Receipts | HMAC-SHA256 | Set a persistent RECEIPT_SECRET; use an external transparency log when independently verifiable receipts are required |
| SSRF | HTTPS-only URLs, redirect validation, and IP-range blocking | Add signed URL enforcement and network-level egress controls |
| Rate limiting |
express-rate-limit, 100 requests per 15 minutes per IP |
Tune limits and use the Redis rate-limit store or an upstream gateway for multiple instances |
| Request limits | Configurable JSON, upload, and remote-download size limits | Set limits for expected assets and enforce matching limits at the reverse proxy |
| TLS | Application serves plain HTTP | Terminate TLS at a trusted reverse proxy or managed load balancer |
byContent extraction |
Extractor interface and variation-selector text plugin | Integrate supported watermark/fingerprint vendor SDKs and monitor extraction cost, latency, and failures |