@fluid-app/droplet-sdk

中文文档
0.2.152发布于 2026/11/12UNLICENSEDTypeScriptES Module近 30 天下载 2.3万

Signature verification and tenancy for Fluid droplet callbacks and webhooks

原始使用文档
文档目录8 节
  1. Install
  2. Verifying a callback
  3. Before you enforce: populating the token store
  4. The three things most likely to bite you
  5. Reading status and receiving reconcile events
  6. Liveness probe
  7. Exports
  8. License

@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

SubpathContents
.verifySignature, verifyLivenessProbe, tokenDigest, backfillCallbackTokens, describeError, createFluidInstallationClient, status schemas/types, health helpers, store types
/nextwithFluidCallback, withFluidWebhook, withFluidLiveness, withFluidReconcileEvents, createInMemoryReconcileDedupe, reconcile schemas/types
/prismacreatePrismaCallbackStore
/drizzlecreateDrizzleCallbackStore — 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.

版本与发布记录

@fluid-app/droplet-sdk 共发布 153 个版本,首次发布于 2026/09/18;过去 12 个月发布了 153 个版本。

版本发布日期距今
0.2.152latest2026/11/123 天前
0.2.1512026/11/123 天前
0.2.1502026/11/069 天前
0.2.1492026/11/069 天前
0.2.1482026/11/069 天前
0.2.1472026/11/069 天前

关于 @fluid-app/droplet-sdk 的常见问题

@fluid-app/droplet-sdk 是什么?

Signature verification and tenancy for Fluid droplet callbacks and webhooks

如何安装 @fluid-app/droplet-sdk?

在项目目录运行 npm install @fluid-app/droplet-sdk,或使用 pnpm add / yarn add / bun add。

@fluid-app/droplet-sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@fluid-app/droplet-sdk 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@fluid-app/droplet-sdk 可以商用吗?

@fluid-app/droplet-sdk 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

@fluid-app/droplet-sdk 的下载量有多少?

根据 npm 官方下载统计,@fluid-app/droplet-sdk 在 2026/10/15 – 2026/11/13 这 30 天内被下载 23,117 次,日均约 771 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@fluid-app/droplet-sdk 还在维护吗?

最新版本 0.2.152 发布于 2026/11/12(3 天前);至今共发布 153 个版本,其中过去 12 个月发布了 153 个;首次发布于 2026/09/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。