@fluid-app/droplet-sdk
Signature verification and tenant resolution for Fluid droplet callbacks and webhooks. Zero runtime dependencies.
Fluid signs each callback with HMAC-SHA256 over {timestamp}.{body} and sends
the registration's own verification token alongside it. This package verifies
that, and resolves which tenant the request belongs to from the registration
rather than from anything in the payload.
Install
Public on npmjs, so there is no .npmrc, no token, and nothing to configure in
CI or in a Dockerfile:
pnpm add @fluid-app/droplet-sdk
Verifying a callback
import { withFluidCallback } from "@fluid-app/droplet-sdk/next";
import { createPrismaCallbackStore } from "@fluid-app/droplet-sdk/prisma";
const callbackStore = createPrismaCallbackStore(
prisma.fluidCallbackRegistration,
);
export const POST = withFluidCallback(
{
definitions: ["cart_item_added"],
store: callbackStore,
resolvePrincipal: async ({ registration }) =>
registration.dri
? prisma.company.findFirst({
where: { dropletInstallationUuid: registration.dri, active: true },
})
: null,
},
async ({ payload, principal, definition }) => {
// `principal` is the verified tenant. `definition` is the one the
// registration was issued for — not a field the caller declared.
return { success: true };
},
);
The store needs one table. A Prisma fragment ships with the package at
@fluid-app/droplet-sdk/schema/callback-registrations.prisma; the token is
stored as a digest, never in the clear.
Before you enforce: populating the token store
Verification matches the token presented on a request against a digest this droplet has stored. An installation that predates the store has none, so every callback would be rejected the moment verification is switched on.
backfillCallbackTokens fills it. Fluid returns verification_token on its
registration listing as well as on create, so existing registrations can be
adopted without re-registering and without a reinstall:
import { backfillCallbackTokens } from "@fluid-app/droplet-sdk";
const result = await backfillCallbackTokens({
client, // anything with listCallbacks({ page, per_page })
store, // your CallbackTokenStore
dri: installation.dropletInstallationUuid,
dropletUrl: process.env.FLUID_DROPLET_URL!,
// Every callback url this droplet registers. Matched exactly: the listing is
// company-scoped and includes other droplets' registrations, so this is what
// decides which tokens are yours to adopt.
ownUrls: callbacks.map((c) => `${process.env.FLUID_DROPLET_URL}${c.url}`),
});
Run it for every active installation before deploying the wrapped routes. A partial backfill means the installations it missed start being refused.
"Succeeded" is not "did not throw", and a count is not coverage. Two
token-bearing registrations for one definition and none for another gives
stored: 2 with an empty missingToken — which looks complete and is not.
Check the definitions actually adopted:
// `definition_name` is the field Fluid uses, and what droplet configs carry.
const expected = callbacks
.filter((c) => c.enabled !== false)
.map((c) => c.definition_name);
if (result.missingToken.length > 0) {
// These registrations exist but the listing returned no token for them, so
// they cannot be adopted. They need re-registering, not a retry.
throw new Error(
`${dri}: no token listed for ${result.missingToken.join(", ")}`,
);
}
const covered = new Set(result.adopted.map((r) => r.definitionName));
const uncovered = expected.filter((d) => !covered.has(d));
if (uncovered.length > 0) {
throw new Error(`${dri}: no stored token for ${uncovered.join(", ")}`);
}
Require that for every installation and treat a failure as a blocker on the deploy, not something to fix afterwards.
One limit remains, and it is about what stays behind rather than what the check
sees. adopted reflects the current listing, so a stale row cannot satisfy the
check — but the store is upsert-only, so that stale row is still there, and it
will still verify a token Fluid no longer issues for a registration that has
been replaced. Nothing is refused because of it; it is a credential that outlives
its registration.
CallbackTokenStore exposes deleteForInstallation, so the way to be certain
an installation holds exactly its current registrations is to clear it and
backfill again, in that order, for that installation only:
await store.deleteForInstallation(dri);
const result = await backfillCallbackTokens({ ...opts, dri });
// then assert coverage as above
Do that when a definition has been re-registered. A plain backfill is enough otherwise.
The three things most likely to bite you
resolvePrincipal must not read the payload. A valid signature proves only
which registration signed — not who the request is about. Taking the tenant
from body.company.id or x-fluid-shop lets the holder of tenant A's token
sign a request naming tenant B. Resolve from registration.dri and return
null if it does not resolve; null is treated as an auth failure.
Fail-open routes answer 200 for every outcome. Callbacks on the checkout
path must not reject — Fluid blocks the storefront request on the response, so a
401 is a broken cart. Give those routes an onAuthFailure that returns their
neutral shape:
onAuthFailure: () => Response.json({ tax_total: 0, lines: [] }),
which means status tells you nothing. Alert on the [fluid-callback:…] rejected log line, never on the status code.
A webhook resolve must return null for "unknown", and throw only for
"could not find out". The wrapper treats the two differently because Fluid
does: a 4xx is recorded as a permanent rejection and never redelivered, while a
5xx and a 429 are retried. So null answers 401, and a thrown error answers 503
via onResolveError.
Getting this backwards is quiet and expensive. A resolve that swallows its own
database errors and returns null turns an outage into a wall of permanent
rejections — and if one of them is droplet.installed, that company has no row,
no webhook of theirs will ever resolve again, and the only trace is a 401. Let
the error propagate:
resolve: async ({ dri }) => {
const company = await db.company.findUnique({ where: { dri } }); // may throw
if (!company?.webhookVerificationToken) return null; // settled: no such tenant
return { secret: company.webhookVerificationToken, principal: company.id };
},
Reading status and receiving reconcile events
import { createFluidInstallationClient } from "@fluid-app/droplet-sdk";
const client = createFluidInstallationClient({ installToken, baseUrl });
const result = await client.getInstallationStatus();
if (result.ok) console.log(result.status.droplet_installation_status);
import {
createInMemoryReconcileDedupe,
withFluidReconcileEvents,
} from "@fluid-app/droplet-sdk/next";
export const POST = withFluidReconcileEvents(
async (event) => {
await handleReconcile(event);
},
{ secret: webhookSecret, dedupe: createInMemoryReconcileDedupe() },
);
Use the droplet's own webhook secret. dedupe is required; there is no default
store. The explicit in-memory store above is per-process and suitable for a
single instance. Across instances, a shared store implementing atomic
claim, complete and release is required (for example Redis SET NX PX
with atomic token checks for completion and release).
The claim methods may return synchronously or as promises:
type ReconcileClaim =
| { status: "claimed"; token: string }
| { status: "in_flight" }
| { status: "done" };
claim(id: string, ttlMs: number): ReconcileClaim | Promise<ReconcileClaim>;
complete(id: string, token: string, ttlMs: number): void | Promise<void>;
release(id: string, token: string): void | Promise<void>;
claim must atomically reserve an available id with a fresh token. Its ttlMs
is the in-flight lease only: Math.min(inFlightTtlMs, dedupeTtlMs).
The wrapper's inFlightTtlMs defaults to 60,000 ms and must be positive and finite; keep it under about 10 minutes, the span of Fluid's
retries, or a crashed instance's lease can outlast every retry. dedupeTtlMs must
also be positive and finite.
An active
claim returns in_flight: the wrapper responds 503 (a 4xx would be recorded by Fluid as a permanent rejection) { error: "in flight" }
without calling the handler, so Fluid retries later. A completed delivery returns
done: the wrapper responds 200 { ok: true, duplicate: true }.
A throwing claim returns a retryable 500 without running the handler.
Implement complete and release alongside claim. After an ok handler response,
complete(id, token, ttlMs) receives dedupeTtlMs for done retention from
completion (default 86,400,000 ms). On a throw
or non-ok response, release(id, token) frees the claim so a retry can run.
Both operations must atomically check that the token still owns an unexpired
claim; otherwise they do nothing. After expiry, a new delivery can claim the id
with a new token, and the old handler cannot complete or release its claim.
Completion and release failures are logged with the event id only and preserve
the handler's response (or 500 for a thrown handler).
The explicit in-memory store implements all three operations. It also caps leases
by its own inFlightTtlMs option (default 60,000 ms). For longer handlers, set
inFlightTtlMs on both the wrapper and createInMemoryReconcileDedupe.
After signature verification and JSON parsing, unknown reconcile.* event kinds
return 200 { ok: true, ignored: true } without claiming or calling the handler.
Only the event name is logged. Non-reconcile or missing event names still return
400 for invalid payloads.
Only atomic stores are accepted: seen/mark stores are not supported, because
they cannot exclude concurrent deliveries of one event id. A shared atomic store
(for example Redis or a database row with a unique id) is required for
multi-instance deployments; createInMemoryReconcileDedupe() is per process and
suits a single instance only. Keep handlers idempotent, including for retries after
TTL expiry or bounded in-memory eviction.
Bodies are limited to 1,048,576 bytes before signature verification; configure
maxBodyBytes to change this limit.
Liveness probe
Fluid sends a signed GET to your manifest's health.path (default /api/health) every minute and counts only a 2xx as healthy.
// app/api/health/route.ts
import { withFluidLiveness } from "@fluid-app/droplet-sdk/next";
export const GET = withFluidLiveness({
check: async () => void (await db.$queryRaw`SELECT 1`),
});
The secret defaults to FLUID_PROBE_SIGNING_SECRET. check runs only for validly signed probes, is bounded by checkTimeoutMs (default 2000), and answers 503 on throw, false or timeout. On timeout it aborts the AbortSignal passed to check, so hand it to your driver. Checks never pile up: concurrent probes share one check, and a timed-out check that is still running makes probes answer 503 without starting another. Unsigned requests get a plain 200; rejected signatures are logged at most once a minute. Middleware must not redirect or auth-gate this path.
Exports
| Subpath | Contents |
|---|---|
. | verifySignature, verifyLivenessProbe, tokenDigest, backfillCallbackTokens, describeError, createFluidInstallationClient, status schemas/types, health helpers, store types |
/next | withFluidCallback, withFluidWebhook, withFluidLiveness, withFluidReconcileEvents, createInMemoryReconcileDedupe, reconcile schemas/types |
/prisma | createPrismaCallbackStore |
/drizzle | createDrizzleCallbackStore — structurally typed, takes eq injected, so this package never depends on drizzle-orm |
License
UNLICENSED — deliberately. This package is public on npmjs so that the
standalone droplet repos can install it without a registry token, which is the
only reason it is published at all. Public availability is not a grant of
rights: no permission to use, copy, modify or redistribute is given.