@fluid-app/droplet-sdk 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # @fluid-app/droplet-sdk
2
+
3
+ Signature verification and tenant resolution for Fluid droplet callbacks and
4
+ webhooks. Zero runtime dependencies.
5
+
6
+ Fluid signs each callback with `HMAC-SHA256` over `{timestamp}.{body}` and sends
7
+ the registration's own verification token alongside it. This package verifies
8
+ that, and resolves which tenant the request belongs to from the registration
9
+ rather than from anything in the payload.
10
+
11
+ ## Install
12
+
13
+ Public on npmjs, so there is no `.npmrc`, no token, and nothing to configure in
14
+ CI or in a Dockerfile:
15
+
16
+ ```bash
17
+ pnpm add @fluid-app/droplet-sdk
18
+ ```
19
+
20
+ ## Verifying a callback
21
+
22
+ ```ts
23
+ import { withFluidCallback } from "@fluid-app/droplet-sdk/next";
24
+ import { createPrismaCallbackStore } from "@fluid-app/droplet-sdk/prisma";
25
+
26
+ const callbackStore = createPrismaCallbackStore(
27
+ prisma.fluidCallbackRegistration,
28
+ );
29
+
30
+ export const POST = withFluidCallback(
31
+ {
32
+ definitions: ["cart_item_added"],
33
+ store: callbackStore,
34
+ resolvePrincipal: async ({ registration }) =>
35
+ registration.dri
36
+ ? prisma.company.findFirst({
37
+ where: { dropletInstallationUuid: registration.dri, active: true },
38
+ })
39
+ : null,
40
+ },
41
+ async ({ payload, principal, definition }) => {
42
+ // `principal` is the verified tenant. `definition` is the one the
43
+ // registration was issued for — not a field the caller declared.
44
+ return { success: true };
45
+ },
46
+ );
47
+ ```
48
+
49
+ The store needs one table. A Prisma fragment ships with the package at
50
+ `@fluid-app/droplet-sdk/schema/callback-registrations.prisma`; the token is
51
+ stored as a **digest**, never in the clear.
52
+
53
+ ## Before you enforce: populating the token store
54
+
55
+ Verification matches the token presented on a request against a digest this
56
+ droplet has stored. An installation that predates the store has none, so every
57
+ callback would be rejected the moment verification is switched on.
58
+
59
+ `backfillCallbackTokens` fills it. Fluid returns `verification_token` on its
60
+ registration listing as well as on create, so existing registrations can be
61
+ adopted without re-registering and without a reinstall:
62
+
63
+ ```ts
64
+ import { backfillCallbackTokens } from "@fluid-app/droplet-sdk";
65
+
66
+ const result = await backfillCallbackTokens({
67
+ client, // anything with listCallbacks({ page, per_page })
68
+ store, // your CallbackTokenStore
69
+ dri: installation.dropletInstallationUuid,
70
+ dropletUrl: process.env.FLUID_DROPLET_URL!,
71
+ // Every callback url this droplet registers. Matched exactly: the listing is
72
+ // company-scoped and includes other droplets' registrations, so this is what
73
+ // decides which tokens are yours to adopt.
74
+ ownUrls: callbacks.map((c) => `${process.env.FLUID_DROPLET_URL}${c.url}`),
75
+ });
76
+ ```
77
+
78
+ Run it for every active installation **before** deploying the wrapped routes. A
79
+ partial backfill means the installations it missed start being refused.
80
+
81
+ "Succeeded" is not "did not throw", and a count is not coverage. Two
82
+ token-bearing registrations for one definition and none for another gives
83
+ `stored: 2` with an empty `missingToken` — which looks complete and is not.
84
+ Check the definitions actually adopted:
85
+
86
+ ```ts
87
+ // `definition_name` is the field Fluid uses, and what droplet configs carry.
88
+ const expected = callbacks
89
+ .filter((c) => c.enabled !== false)
90
+ .map((c) => c.definition_name);
91
+
92
+ if (result.missingToken.length > 0) {
93
+ // These registrations exist but the listing returned no token for them, so
94
+ // they cannot be adopted. They need re-registering, not a retry.
95
+ throw new Error(
96
+ `${dri}: no token listed for ${result.missingToken.join(", ")}`,
97
+ );
98
+ }
99
+
100
+ const covered = new Set(result.adopted.map((r) => r.definitionName));
101
+ const uncovered = expected.filter((d) => !covered.has(d));
102
+ if (uncovered.length > 0) {
103
+ throw new Error(`${dri}: no stored token for ${uncovered.join(", ")}`);
104
+ }
105
+ ```
106
+
107
+ Require that for every installation and treat a failure as a blocker on the
108
+ deploy, not something to fix afterwards.
109
+
110
+ One limit remains, and it is about what stays behind rather than what the check
111
+ sees. `adopted` reflects the current listing, so a stale row cannot satisfy the
112
+ check — but the store is upsert-only, so that stale row is still there, and it
113
+ will still verify a token Fluid no longer issues for a registration that has
114
+ been replaced. Nothing is refused because of it; it is a credential that outlives
115
+ its registration.
116
+
117
+ `CallbackTokenStore` exposes `deleteForInstallation`, so the way to be certain
118
+ an installation holds exactly its current registrations is to clear it and
119
+ backfill again, in that order, for that installation only:
120
+
121
+ ```ts
122
+ await store.deleteForInstallation(dri);
123
+ const result = await backfillCallbackTokens({ ...opts, dri });
124
+ // then assert coverage as above
125
+ ```
126
+
127
+ Do that when a definition has been re-registered. A plain backfill is enough
128
+ otherwise.
129
+
130
+ ## The three things most likely to bite you
131
+
132
+ **`resolvePrincipal` must not read the payload.** A valid signature proves only
133
+ _which registration_ signed — not who the request is about. Taking the tenant
134
+ from `body.company.id` or `x-fluid-shop` lets the holder of tenant A's token
135
+ sign a request naming tenant B. Resolve from `registration.dri` and return
136
+ `null` if it does not resolve; `null` is treated as an auth failure.
137
+
138
+ **Fail-open routes answer 200 for every outcome.** Callbacks on the checkout
139
+ path must not reject — Fluid blocks the storefront request on the response, so a
140
+ 401 is a broken cart. Give those routes an `onAuthFailure` that returns their
141
+ neutral shape:
142
+
143
+ ```ts
144
+ onAuthFailure: () => Response.json({ tax_total: 0, lines: [] }),
145
+ ```
146
+
147
+ which means status tells you nothing. **Alert on the `[fluid-callback:…]
148
+ rejected` log line, never on the status code.**
149
+
150
+ **A webhook `resolve` must return `null` for "unknown", and throw only for
151
+ "could not find out".** The wrapper treats the two differently because Fluid
152
+ does: a 4xx is recorded as a permanent rejection and never redelivered, while a
153
+ 5xx and a 429 are retried. So `null` answers 401, and a thrown error answers 503
154
+ via `onResolveError`.
155
+
156
+ Getting this backwards is quiet and expensive. A `resolve` that swallows its own
157
+ database errors and returns `null` turns an outage into a wall of permanent
158
+ rejections — and if one of them is `droplet.installed`, that company has no row,
159
+ no webhook of theirs will ever resolve again, and the only trace is a 401. Let
160
+ the error propagate:
161
+
162
+ ```ts
163
+ resolve: async ({ dri }) => {
164
+ const company = await db.company.findUnique({ where: { dri } }); // may throw
165
+ if (!company?.webhookVerificationToken) return null; // settled: no such tenant
166
+ return { secret: company.webhookVerificationToken, principal: company.id };
167
+ },
168
+ ```
169
+
170
+ ## Exports
171
+
172
+ | Subpath | Contents |
173
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
174
+ | `.` | `verifySignature`, `tokenDigest`, `backfillCallbackTokens`, `describeError`, store types |
175
+ | `/next` | `withFluidCallback`, `withFluidWebhook` |
176
+ | `/prisma` | `createPrismaCallbackStore` |
177
+ | `/drizzle` | `createDrizzleCallbackStore` — structurally typed, takes `eq` injected, so this package never depends on `drizzle-orm` |
178
+
179
+ ## License
180
+
181
+ `UNLICENSED` — deliberately. This package is public on npmjs so that the
182
+ standalone droplet repos can install it without a registry token, which is the
183
+ only reason it is published at all. Public availability is not a grant of
184
+ rights: no permission to use, copy, modify or redistribute is given.
@@ -0,0 +1,108 @@
1
+ import { CallbackTokenStore } from "./store/types.mjs";
2
+ import { Logger } from "./redact.mjs";
3
+
4
+ //#region src/backfill.d.ts
5
+ /**
6
+ * The subset of a Fluid client this backfill needs.
7
+ *
8
+ * Structural, so any client exposing this method satisfies it and the package
9
+ * does not depend on a particular client implementation.
10
+ */
11
+ interface CallbackListingClient {
12
+ /**
13
+ * `GET /api/callback/registrations`.
14
+ *
15
+ * The params are optional so a zero-argument `listCallbacks()` still
16
+ * satisfies this type — but a client that ignores them caps the backfill at
17
+ * Fluid's default page size, so prefer one that forwards them.
18
+ */
19
+ listCallbacks(params?: {
20
+ page?: number;
21
+ per_page?: number;
22
+ }): Promise<{
23
+ callback_registrations: Array<{
24
+ uuid: string;
25
+ definition_name: string;
26
+ url: string;
27
+ verification_token?: string;
28
+ }>;
29
+ }>;
30
+ }
31
+ interface BackfillResult {
32
+ stored: number;
33
+ skipped: number;
34
+ /** definition_name values whose registration carried no token. */
35
+ missingToken: string[];
36
+ /** Registrations belonging to a different droplet, skipped. */
37
+ foreign: number;
38
+ /**
39
+ * What was actually adopted, in the order it was stored.
40
+ *
41
+ * `stored` alone cannot establish coverage: two token-bearing registrations
42
+ * for one definition and none for another give `stored: 2` with an empty
43
+ * `missingToken`, which looks complete and is not. Callers that need to know
44
+ * every expected definition is covered — which is every caller about to
45
+ * enable verification — have to compare the definitions adopted against the
46
+ * definitions they expect.
47
+ */
48
+ adopted: Array<{
49
+ uuid: string;
50
+ definitionName: string;
51
+ url: string;
52
+ }>;
53
+ }
54
+ /**
55
+ * Populates the callback token store for an installation that predates the SDK.
56
+ *
57
+ * Fluid returns `verification_token` on its registration listing as well as on
58
+ * create, so existing registrations are adopted without re-registering and
59
+ * without a reinstall.
60
+ *
61
+ * Until this has run an installation has no stored tokens, every lookup misses,
62
+ * and enforcing verification would reject its callbacks. Run it, for every
63
+ * installation, before deploying wrapped routes.
64
+ *
65
+ * `ownUrls` must list the exact callback urls this droplet registers. The
66
+ * listing is company-scoped and includes other droplets' registrations, so this
67
+ * is what decides which tokens may be adopted; anything else is counted in
68
+ * `foreign` and skipped. Set `FLUID_DROPLET_ALT_URL` if the same service is
69
+ * also served on a second address.
70
+ *
71
+ * @returns counts of what was `stored`, `skipped` and `foreign`, plus
72
+ * `missingToken` — definitions whose registration carried no token and which
73
+ * therefore cannot be adopted at all.
74
+ */
75
+ declare function backfillCallbackTokens({
76
+ client,
77
+ store,
78
+ dri,
79
+ dropletUrl,
80
+ ownUrls,
81
+ logger
82
+ }: {
83
+ client: CallbackListingClient;
84
+ store: CallbackTokenStore;
85
+ dri: string;
86
+ /**
87
+ * This droplet's public base URL (`FLUID_DROPLET_URL`).
88
+ *
89
+ * Required, and validated: without it there is no way to tell our
90
+ * registrations from another droplet's, and defaulting to "adopt everything"
91
+ * would hand us their tokens. An absent value is a misconfiguration, so it
92
+ * throws rather than quietly adopting the lot.
93
+ */
94
+ dropletUrl: string;
95
+ /**
96
+ * The exact callback URLs this droplet registers. Only registrations whose
97
+ * url is in this set are adopted.
98
+ *
99
+ * Required, and must not be empty. Matching on origin alone is not
100
+ * sufficient: it accepts ANY path on this host, so a co-installed droplet
101
+ * could register one of our definitions at a path we do not serve and have
102
+ * its token adopted as ours.
103
+ */
104
+ ownUrls: string[];
105
+ logger?: Logger;
106
+ }): Promise<BackfillResult>;
107
+ //#endregion
108
+ export { BackfillResult, CallbackListingClient, backfillCallbackTokens };
@@ -0,0 +1,112 @@
1
+ import { tokenDigest } from "./signatures.mjs";
2
+ import { consoleLogger, describeError } from "./redact.mjs";
3
+ //#region src/backfill.ts
4
+ const PAGE_SIZE = 100;
5
+ const MAX_PAGES = 50;
6
+ function canonicalUrl(url) {
7
+ try {
8
+ const parsed = new URL(url);
9
+ parsed.pathname = parsed.pathname.replace(/\/{2,}/g, "/");
10
+ return parsed.toString();
11
+ } catch {
12
+ return url;
13
+ }
14
+ }
15
+ function siblingOriginFromEnv() {
16
+ const alt = process.env.FLUID_DROPLET_ALT_URL?.trim();
17
+ if (!alt) return null;
18
+ try {
19
+ const parsed = new URL(alt);
20
+ if (parsed.protocol !== "https:") return null;
21
+ return parsed.origin;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function backfillCallbackTokens({ client, store, dri, dropletUrl, ownUrls, logger = consoleLogger }) {
27
+ const ownOrigin = originOf(dropletUrl);
28
+ if (!ownOrigin) throw new Error("backfillCallbackTokens requires dropletUrl (FLUID_DROPLET_URL) to be a valid absolute URL; without it, another droplet's registrations cannot be told apart from ours");
29
+ const siblingOrigin = siblingOriginFromEnv();
30
+ const withSibling = (url) => {
31
+ if (!siblingOrigin) return [url];
32
+ let parsed;
33
+ try {
34
+ parsed = new URL(url);
35
+ } catch {
36
+ return [url];
37
+ }
38
+ if (parsed.origin !== ownOrigin) return [url];
39
+ return [url, canonicalUrl(`${siblingOrigin}${parsed.pathname}${parsed.search}${parsed.hash}`)];
40
+ };
41
+ if (!ownUrls || ownUrls.length === 0) throw new Error("backfillCallbackTokens requires ownUrls: the exact callback urls this droplet registers. Without them there is no safe way to tell our registrations from a co-installed droplet's on the same host");
42
+ const ownUrlSet = new Set(ownUrls.map(canonicalUrl).flatMap(withSibling));
43
+ const result = {
44
+ stored: 0,
45
+ skipped: 0,
46
+ adopted: [],
47
+ missingToken: [],
48
+ foreign: 0
49
+ };
50
+ const registrations = [];
51
+ const seen = new Set();
52
+ try {
53
+ for (let page = 1; page <= MAX_PAGES; page++) {
54
+ const batch = (await client.listCallbacks({
55
+ page,
56
+ per_page: PAGE_SIZE
57
+ })).callback_registrations ?? [];
58
+ const fresh = batch.filter((r) => !seen.has(r.uuid));
59
+ fresh.forEach((r) => seen.add(r.uuid));
60
+ registrations.push(...fresh);
61
+ if (batch.length === 0 || fresh.length === 0) break;
62
+ if (page === MAX_PAGES) logger.warn("[backfill] stopped at the page limit; some registrations may not be stored", {
63
+ pages: MAX_PAGES,
64
+ collected: registrations.length
65
+ });
66
+ }
67
+ } catch (error) {
68
+ logger.error("[backfill] could not list callback registrations", describeError(error));
69
+ throw error;
70
+ }
71
+ for (const registration of registrations) {
72
+ if (!ownUrlSet.has(registration.url)) {
73
+ result.foreign++;
74
+ continue;
75
+ }
76
+ if (!registration.verification_token || !registration.uuid) {
77
+ result.skipped++;
78
+ result.missingToken.push(registration.definition_name);
79
+ continue;
80
+ }
81
+ await store.upsert({
82
+ uuid: registration.uuid,
83
+ dri,
84
+ definitionName: registration.definition_name,
85
+ tokenDigest: tokenDigest(registration.verification_token),
86
+ url: registration.url
87
+ });
88
+ result.adopted.push({
89
+ uuid: registration.uuid,
90
+ definitionName: registration.definition_name,
91
+ url: registration.url
92
+ });
93
+ result.stored++;
94
+ }
95
+ if (result.missingToken.length > 0) logger.warn("[backfill] registrations returned without a verification_token; these cannot be verified until re-registered", { definitions: result.missingToken });
96
+ logger.info("[backfill] complete", {
97
+ stored: result.stored,
98
+ skipped: result.skipped,
99
+ foreign: result.foreign
100
+ });
101
+ return result;
102
+ }
103
+ function originOf(url) {
104
+ if (!url) return null;
105
+ try {
106
+ return new URL(url).origin;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+ //#endregion
112
+ export { backfillCallbackTokens };
@@ -0,0 +1,41 @@
1
+ //#region src/checkout.d.ts
2
+ /**
3
+ * The published shape is declared, not inferred.
4
+ *
5
+ * Deriving the exported type with `z.infer` made the package's public API a
6
+ * function of whichever zod version resolved at build time, and it is also
7
+ * what `isolatedDeclarations` refuses — the declaration emitter cannot write
8
+ * this type without type-checking the schema. Declaring it and annotating the
9
+ * schema against it inverts that: the interface is the contract, and the
10
+ * validator has to satisfy it or the build fails.
11
+ */
12
+ interface CheckoutCartChange {
13
+ cart_token: string;
14
+ sequence: number;
15
+ email: string | null;
16
+ state: string | null;
17
+ }
18
+ interface HostMessage {
19
+ source: unknown;
20
+ origin: string;
21
+ data: unknown;
22
+ }
23
+ interface CheckoutMessageTarget {
24
+ parent: {
25
+ postMessage(message: unknown, origin: string): void;
26
+ };
27
+ addEventListener(type: "message", listener: (event: HostMessage) => void): void;
28
+ removeEventListener(type: "message", listener: (event: HostMessage) => void): void;
29
+ document?: {
30
+ readonly visibilityState: string;
31
+ addEventListener(type: "visibilitychange", listener: () => void): void;
32
+ removeEventListener(type: "visibilitychange", listener: () => void): void;
33
+ };
34
+ }
35
+ declare function watchCheckoutCart(options: {
36
+ cartToken: string;
37
+ refresh: (change?: CheckoutCartChange) => void | Promise<void>;
38
+ target: CheckoutMessageTarget;
39
+ }): () => void;
40
+ //#endregion
41
+ export { CheckoutCartChange, CheckoutMessageTarget, watchCheckoutCart };
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ //#region src/checkout.ts
3
+ const checkoutCartEventSchema = z.object({
4
+ type: z.literal("FLUID_CART_UPDATED"),
5
+ version: z.literal(1),
6
+ payload: z.object({
7
+ cart_token: z.string(),
8
+ sequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
9
+ email: z.string().nullable(),
10
+ state: z.string().nullable()
11
+ })
12
+ });
13
+ function watchCheckoutCart(options) {
14
+ if (!options.cartToken.trim()) return () => {};
15
+ const target = options.target;
16
+ let stopped = false;
17
+ let running = false;
18
+ let pending = false;
19
+ let latest;
20
+ let retries = 0;
21
+ let retryTimer;
22
+ const refresh = async () => {
23
+ if (stopped) return;
24
+ if (target.document?.visibilityState === "hidden" || running) {
25
+ pending = true;
26
+ return;
27
+ }
28
+ clearTimeout(retryTimer);
29
+ running = true;
30
+ try {
31
+ await options.refresh(latest);
32
+ retries = 0;
33
+ } catch {
34
+ if (!stopped && retries < 2) retryTimer = setTimeout(() => {
35
+ refresh();
36
+ }, [1e3, 3e3][retries++]);
37
+ } finally {
38
+ running = false;
39
+ if (pending && !stopped) {
40
+ pending = false;
41
+ refresh();
42
+ }
43
+ }
44
+ };
45
+ const announceReady = () => target.parent.postMessage({
46
+ type: "FLUID_CART_READY",
47
+ version: 1,
48
+ cart_token: options.cartToken
49
+ }, "*");
50
+ const fallback = setInterval(() => {
51
+ refresh();
52
+ }, 3e4);
53
+ const receive = (event) => {
54
+ if (event.source !== target.parent || !trustedCheckoutOrigin(event.origin)) return;
55
+ const parsed = checkoutCartEventSchema.safeParse(event.data);
56
+ if (!parsed.success || parsed.data.payload.cart_token !== options.cartToken) return;
57
+ const change = parsed.data.payload;
58
+ clearInterval(fallback);
59
+ if (latest && change.sequence <= latest.sequence) return;
60
+ latest = change;
61
+ retries = 0;
62
+ clearTimeout(retryTimer);
63
+ refresh();
64
+ };
65
+ target.addEventListener("message", receive);
66
+ const resume = () => {
67
+ if (target.document?.visibilityState === "hidden") return;
68
+ if (pending) {
69
+ pending = false;
70
+ refresh();
71
+ }
72
+ announceReady();
73
+ };
74
+ target.document?.addEventListener("visibilitychange", resume);
75
+ refresh();
76
+ announceReady();
77
+ return () => {
78
+ stopped = true;
79
+ clearInterval(fallback);
80
+ clearTimeout(retryTimer);
81
+ target.removeEventListener("message", receive);
82
+ target.document?.removeEventListener("visibilitychange", resume);
83
+ };
84
+ }
85
+ function trustedCheckoutOrigin(origin) {
86
+ try {
87
+ const url = new URL(origin);
88
+ return url.protocol === "https:" && (url.hostname === "fluid.app" || url.hostname.endsWith(".fluid.app")) || [
89
+ "localhost",
90
+ "127.0.0.1",
91
+ "checkout.fluid.test"
92
+ ].includes(url.hostname) && ["http:", "https:"].includes(url.protocol);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+ //#endregion
98
+ export { watchCheckoutCart };
@@ -0,0 +1,2 @@
1
+ import { DrizzleCallbackTable, DrizzleLike, EqFn, createDrizzleCallbackStore } from "./store/drizzle.mjs";
2
+ export { type DrizzleCallbackTable, type DrizzleLike, type EqFn, createDrizzleCallbackStore };
@@ -0,0 +1,2 @@
1
+ import { createDrizzleCallbackStore } from "./store/drizzle.mjs";
2
+ export { createDrizzleCallbackStore };
@@ -0,0 +1,6 @@
1
+ import { CallbackTokenStore, StoredRegistration } from "./store/types.mjs";
2
+ import { MAX_SIGNATURE_AGE_SECONDS, SignatureFailureReason, SignatureResult, VerifySignatureInput, tokenDigest, verifySignature } from "./signatures.mjs";
3
+ import { Logger, consoleLogger, describeError, redactValue } from "./redact.mjs";
4
+ import { BackfillResult, CallbackListingClient, backfillCallbackTokens } from "./backfill.mjs";
5
+ import { CallbackVerificationReadiness, reportCallbackVerificationReadiness } from "./readiness.mjs";
6
+ export { type BackfillResult, type CallbackListingClient, type CallbackTokenStore, type CallbackVerificationReadiness, type Logger, MAX_SIGNATURE_AGE_SECONDS, type SignatureFailureReason, type SignatureResult, type StoredRegistration, type VerifySignatureInput, backfillCallbackTokens, consoleLogger, describeError, redactValue, reportCallbackVerificationReadiness, tokenDigest, verifySignature };
package/dist/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ import { MAX_SIGNATURE_AGE_SECONDS, tokenDigest, verifySignature } from "./signatures.mjs";
2
+ import { consoleLogger, describeError, redactValue } from "./redact.mjs";
3
+ import { backfillCallbackTokens } from "./backfill.mjs";
4
+ import { reportCallbackVerificationReadiness } from "./readiness.mjs";
5
+ export { MAX_SIGNATURE_AGE_SECONDS, backfillCallbackTokens, consoleLogger, describeError, redactValue, reportCallbackVerificationReadiness, tokenDigest, verifySignature };
@@ -0,0 +1,81 @@
1
+ import { CallbackTokenStore, StoredRegistration } from "../store/types.mjs";
2
+ import { Logger } from "../redact.mjs";
3
+
4
+ //#region src/next/callbacks.d.ts
5
+ interface CallbackContext<Principal> {
6
+ /** Parsed body. Only reached after the signature verified. */
7
+ payload: unknown;
8
+ /** Which of the configured definitions this registration serves. */
9
+ definition: string;
10
+ /** App-resolved tenant. Trustworthy; do not re-derive from payload. */
11
+ principal: Principal;
12
+ /**
13
+ * The registration whose token verified this request.
14
+ *
15
+ * Never null: every path that leaves it unresolved returns before the handler
16
+ * runs.
17
+ */
18
+ registration: StoredRegistration;
19
+ /** The body stream is consumed by the wrapper, so these are passed explicitly. */
20
+ headers: Headers;
21
+ signal: AbortSignal;
22
+ rawBody: string;
23
+ }
24
+ interface CallbackFailure {
25
+ stage: "auth" | "body" | "handler";
26
+ reason: string;
27
+ /** Present only for handler failures. */
28
+ error?: unknown;
29
+ }
30
+ interface WithFluidCallbackConfig<Principal> {
31
+ /**
32
+ * Definition names this route serves.
33
+ *
34
+ * An array because one route legitimately serves several — a single
35
+ * cart-item-changed handler may be registered under five definitions. A token
36
+ * issued for a definition this route does not serve is rejected.
37
+ */
38
+ definitions: string[];
39
+ store: CallbackTokenStore;
40
+ /**
41
+ * Resolves the tenant *after* the signature verifies.
42
+ *
43
+ * Returning null is treated as an auth failure — an authenticated
44
+ * registration whose tenant cannot be resolved is not safe to run.
45
+ */
46
+ resolvePrincipal: (input: {
47
+ registration: StoredRegistration;
48
+ payload: unknown;
49
+ headers: Headers;
50
+ }) => Promise<Principal | null>;
51
+ logger?: Logger;
52
+ /** Label used in log lines. Defaults to the first definition. */
53
+ name?: string;
54
+ /**
55
+ * Failure policy. Defaults fail closed with 401/400/500.
56
+ *
57
+ * Routes on the checkout path must override these — a tax droplet answers a
58
+ * failed calculation with a zero-tax object, and returning a 401 there would
59
+ * break the cart rather than protect it.
60
+ */
61
+ onAuthFailure?: (failure: CallbackFailure) => Response;
62
+ onInvalidBody?: (failure: CallbackFailure) => Response;
63
+ onHandlerError?: (failure: CallbackFailure) => Response;
64
+ }
65
+ type CallbackHandler<Principal> = (context: CallbackContext<Principal>) => Promise<Response | unknown>;
66
+ /**
67
+ * Wraps a Fluid callback route with signature verification.
68
+ *
69
+ * Order of operations matters and is the reason this wrapper exists:
70
+ *
71
+ * 1. read the raw body (the exact bytes Fluid signed)
72
+ * 2. locate the registration by digest of the presented token
73
+ * 3. verify the signature against the *stored* registration
74
+ * 4. only then parse, resolve the tenant, and run the handler
75
+ *
76
+ * Step 1 is why every route cannot simply keep calling `request.json()`:
77
+ * re-serialising a parsed object does not reliably reproduce the signed bytes.
78
+ */
79
+ declare function withFluidCallback<Principal>(config: WithFluidCallbackConfig<Principal>, handler: CallbackHandler<Principal>): (request: Request) => Promise<Response>;
80
+ //#endregion
81
+ export { CallbackContext, CallbackFailure, CallbackHandler, WithFluidCallbackConfig, withFluidCallback };