@fluid-app/droplet-sdk
v0.2.47
Published
Signature verification and tenancy for Fluid droplet callbacks and webhooks
Readme
@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-sdkVerifying 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 aboveDo 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 };
},Exports
| Subpath | Contents |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| . | verifySignature, tokenDigest, backfillCallbackTokens, describeError, store types |
| /next | withFluidCallback, withFluidWebhook |
| /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.
