@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.
@@ -0,0 +1,120 @@
1
+ import { tokenDigest, verifySignature } from "../signatures.mjs";
2
+ import { consoleLogger, describeError } from "../redact.mjs";
3
+ //#region src/next/callbacks.ts
4
+ const HEADER_TIMESTAMP = "x-fluid-timestamp";
5
+ const HEADER_SIGNATURE = "x-fluid-signature";
6
+ const HEADER_CALLBACK_TOKEN = "x-fluid-callback-token";
7
+ const denyAuth = () => Response.json({ error: "unauthorized" }, { status: 401 });
8
+ const denyBody = () => Response.json({ error: "invalid request" }, { status: 400 });
9
+ const denyHandler = () => Response.json({ error: "internal error" }, { status: 500 });
10
+ function withFluidCallback(config, handler) {
11
+ const { definitions, store, resolvePrincipal, logger = consoleLogger, name = definitions[0] ?? "callback", onAuthFailure = denyAuth, onInvalidBody = denyBody, onHandlerError = denyHandler } = config;
12
+ if (definitions.length === 0) throw new Error("withFluidCallback requires at least one definition");
13
+ return async function POST(request) {
14
+ const log = (level, message, context = {}) => logger[level](`[fluid-callback:${name}] ${message}`, context);
15
+ let bodyBytes;
16
+ try {
17
+ bodyBytes = new Uint8Array(await request.arrayBuffer());
18
+ } catch (error) {
19
+ log("error", "could not read request body", describeError(error));
20
+ return onInvalidBody({
21
+ stage: "body",
22
+ reason: "unreadable"
23
+ });
24
+ }
25
+ const rawBody = new TextDecoder().decode(bodyBytes);
26
+ const presentedToken = request.headers.get(HEADER_CALLBACK_TOKEN);
27
+ let registration = null;
28
+ let authFailure = null;
29
+ if (!presentedToken) authFailure = "missing_callback_token";
30
+ else {
31
+ try {
32
+ registration = await store.findByTokenDigest(tokenDigest(presentedToken));
33
+ } catch (error) {
34
+ log("error", "token store lookup failed", describeError(error));
35
+ return onAuthFailure({
36
+ stage: "auth",
37
+ reason: "store_unavailable"
38
+ });
39
+ }
40
+ if (!registration) authFailure = "unknown_registration";
41
+ else if (!definitions.includes(registration.definitionName)) authFailure = "definition_mismatch";
42
+ else {
43
+ const result = verifySignature({
44
+ rawBody: bodyBytes,
45
+ signature: request.headers.get(HEADER_SIGNATURE),
46
+ timestamp: request.headers.get(HEADER_TIMESTAMP),
47
+ secret: presentedToken
48
+ });
49
+ if (!result.valid) authFailure = result.reason;
50
+ }
51
+ }
52
+ if (authFailure) {
53
+ log("warn", "rejected", { reason: authFailure });
54
+ return onAuthFailure({
55
+ stage: "auth",
56
+ reason: authFailure
57
+ });
58
+ }
59
+ if (!registration) {
60
+ log("error", "no registration after verification passed");
61
+ return onAuthFailure({
62
+ stage: "auth",
63
+ reason: "unknown_registration"
64
+ });
65
+ }
66
+ let payload;
67
+ try {
68
+ payload = JSON.parse(rawBody);
69
+ } catch {
70
+ log("warn", "body was not valid JSON");
71
+ return onInvalidBody({
72
+ stage: "body",
73
+ reason: "malformed_json"
74
+ });
75
+ }
76
+ let principal = null;
77
+ try {
78
+ principal = await resolvePrincipal({
79
+ registration,
80
+ payload,
81
+ headers: request.headers
82
+ });
83
+ } catch (error) {
84
+ log("error", "principal resolution threw", describeError(error));
85
+ return onHandlerError({
86
+ stage: "handler",
87
+ reason: "resolve_threw",
88
+ error
89
+ });
90
+ }
91
+ if (principal === null) {
92
+ log("warn", "rejected", { reason: "unresolved_principal" });
93
+ return onAuthFailure({
94
+ stage: "auth",
95
+ reason: "unresolved_principal"
96
+ });
97
+ }
98
+ try {
99
+ const result = await handler({
100
+ payload,
101
+ definition: registration.definitionName,
102
+ principal,
103
+ registration,
104
+ headers: request.headers,
105
+ signal: request.signal,
106
+ rawBody
107
+ });
108
+ return result instanceof Response ? result : Response.json(result);
109
+ } catch (error) {
110
+ log("error", "handler threw", describeError(error));
111
+ return onHandlerError({
112
+ stage: "handler",
113
+ reason: "handler_threw",
114
+ error
115
+ });
116
+ }
117
+ };
118
+ }
119
+ //#endregion
120
+ export { withFluidCallback };
@@ -0,0 +1,119 @@
1
+ import { Logger } from "../redact.mjs";
2
+
3
+ //#region src/next/webhooks.d.ts
4
+ /**
5
+ * The event that arrives before any per-company secret exists, and so the
6
+ * default — and only sensible — member of `bootstrapEvents`.
7
+ */
8
+ declare const INSTALL_EVENT = "droplet.installed";
9
+ interface WebhookRoutingHints {
10
+ /** Parsed but *unverified*. Used only to locate a candidate secret. */
11
+ payload: unknown;
12
+ headers: Headers;
13
+ dri?: string;
14
+ fluidShop?: string;
15
+ companyId?: string | number;
16
+ }
17
+ interface ResolvedWebhookPrincipal<Principal> {
18
+ secret: string;
19
+ principal: Principal;
20
+ }
21
+ interface WebhookContext<Principal> {
22
+ event: string;
23
+ payload: unknown;
24
+ /**
25
+ * The verified tenant.
26
+ *
27
+ * Non-null means the request verified — but NOT necessarily against this
28
+ * company's own secret. An event listed in `bootstrapEvents` verifies against
29
+ * the shared bootstrap secret and ONLY that, so a reinstall is bootstrap-
30
+ * verified while `resolve` still finds the existing company: the handler sees
31
+ * that company on a request no company token could have signed.
32
+ *
33
+ * Null means the bootstrap secret verified and no company was found — a first
34
+ * install, which the handler is expected to create. It is never null for an
35
+ * unverified request: those are refused before the handler runs.
36
+ */
37
+ principal: Principal | null;
38
+ headers: Headers;
39
+ signal: AbortSignal;
40
+ rawBody: string;
41
+ }
42
+ interface WithFluidWebhookConfig<Principal> {
43
+ /**
44
+ * Locates the per-company secret and tenant from unverified routing hints.
45
+ *
46
+ * Unlike callbacks, webhook secrets are per-company, so the tenant must be
47
+ * guessed from untrusted input *before* verification. That is inherent to the
48
+ * scheme: resolve a candidate, verify against it, and only then trust it.
49
+ *
50
+ * The two failure modes are NOT the same and must not be conflated:
51
+ *
52
+ * return null — "there is no such tenant". A settled answer. The request is
53
+ * refused with `onAuthFailure`, and Fluid will not retry it.
54
+ * throw — "I could not find out". The lookup itself failed: the
55
+ * database was unreachable, a query timed out. The request is answered
56
+ * with `onResolveError` (503 by default) so Fluid retries the delivery.
57
+ *
58
+ * Throw for "unknown", and an outage becomes a permanently rejected webhook.
59
+ */
60
+ resolve: (hints: WebhookRoutingHints) => Promise<ResolvedWebhookPrincipal<Principal> | null>;
61
+ /**
62
+ * Shared token accepted only for the events in `bootstrapEvents`, which
63
+ * defaults to `droplet.installed` alone.
64
+ *
65
+ * A first install has no per-company secret yet, so something has to
66
+ * authenticate it. Every other event requires a signature: a shared value
67
+ * accepted generally is a bypass, because one leaked copy authenticates
68
+ * anything.
69
+ *
70
+ * The exclusivity runs BOTH ways. For an event in `bootstrapEvents` this is
71
+ * the only accepted signer and a per-company secret is refused, because Fluid
72
+ * signs those events with the droplet's own secret and never with a
73
+ * company's. Omitting it while listing lifecycle events means those events
74
+ * cannot verify at all (`no_bootstrap_secret`), which is a loud failure by
75
+ * design.
76
+ */
77
+ bootstrapSecret?: string;
78
+ /**
79
+ * The complete set of events permitted to use the bootstrap secret.
80
+ *
81
+ * REPLACES the default rather than adding to it, so a list that omits
82
+ * `droplet.installed` stops installs verifying. Include it explicitly unless
83
+ * that is what you intend. Defaults to `["droplet.installed"]`.
84
+ *
85
+ * Replacement rather than addition is deliberate for a security control: the
86
+ * list is exactly what the caller sees when reading their own code, with no
87
+ * inherited entry to overlook.
88
+ */
89
+ bootstrapEvents?: string[];
90
+ logger?: Logger;
91
+ name?: string;
92
+ onAuthFailure?: (reason: string) => Response;
93
+ onInvalidBody?: (reason: string) => Response;
94
+ /**
95
+ * Answers a `resolve` that threw. Defaults to 503.
96
+ *
97
+ * Must stay a 5xx: Fluid retries 5xx and 429, and treats every other 4xx as
98
+ * a permanent rejection that is never delivered again.
99
+ */
100
+ onResolveError?: (error: unknown) => Response;
101
+ onHandlerError?: (error: unknown) => Response;
102
+ }
103
+ type WebhookHandler<Principal> = (context: WebhookContext<Principal>) => Promise<Response | unknown>;
104
+ /**
105
+ * The object the event was actually derived FROM.
106
+ *
107
+ * `eventOf` accepts several shapes; everything downstream — tenant hints,
108
+ * handler payload — has to look at the same object it chose, or the three
109
+ * disagree. They did: hints were read from the outer envelope, so an enveloped
110
+ * per-company webhook (`{name, payload: {company: {...}}}`) offered no tenant,
111
+ * produced no candidate secret, and failed closed with 401 on every delivery.
112
+ *
113
+ * Precedence mirrors `eventOf` exactly, in the same order.
114
+ */
115
+ declare const effectivePayload: (body: unknown) => unknown;
116
+ /** Wraps a Fluid webhook route with HMAC verification. */
117
+ declare function withFluidWebhook<Principal>(config: WithFluidWebhookConfig<Principal>, handler: WebhookHandler<Principal>): (request: Request) => Promise<Response>;
118
+ //#endregion
119
+ export { INSTALL_EVENT, ResolvedWebhookPrincipal, WebhookContext, WebhookHandler, WebhookRoutingHints, WithFluidWebhookConfig, effectivePayload, withFluidWebhook };
@@ -0,0 +1,150 @@
1
+ import { verifySignature } from "../signatures.mjs";
2
+ import { consoleLogger, describeError } from "../redact.mjs";
3
+ //#region src/next/webhooks.ts
4
+ const HEADER_TIMESTAMP = "x-fluid-timestamp";
5
+ const HEADER_SIGNATURE = "x-fluid-signature";
6
+ const INSTALL_EVENT = "droplet.installed";
7
+ const eventOf = (payload) => {
8
+ if (!payload || typeof payload !== "object") return "unknown";
9
+ const record = payload;
10
+ const name = record["name"];
11
+ if (typeof name === "string" && name.length > 0) return name.includes(".") ? name : name.replace("_", ".");
12
+ const nested = record["payload"] ?? {};
13
+ const resource = record["resource"] ?? nested["resource"];
14
+ const event = record["event"] ?? nested["event"];
15
+ if (typeof resource === "string" && typeof event === "string") return `${resource}.${event}`;
16
+ if (typeof event === "string") return event;
17
+ return "unknown";
18
+ };
19
+ const effectivePayload = (body) => {
20
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
21
+ const record = body;
22
+ const nested = record["payload"];
23
+ const nestedIsObject = typeof nested === "object" && nested !== null && !Array.isArray(nested);
24
+ const name = record["name"];
25
+ if (typeof name === "string" && name.length > 0) return nestedIsObject ? nested : record;
26
+ if (typeof record["resource"] === "string" && typeof record["event"] === "string") return record;
27
+ if (nestedIsObject) {
28
+ const inner = nested;
29
+ if (typeof inner["resource"] === "string" || typeof inner["event"] === "string") return nested;
30
+ }
31
+ return record;
32
+ };
33
+ const readHints = (body) => {
34
+ const payload = effectivePayload(body);
35
+ if (!payload || typeof payload !== "object") return {};
36
+ const record = payload;
37
+ const company = record["company"] ?? {};
38
+ const context = record["context"] ?? {};
39
+ const dri = company["droplet_installation_uuid"];
40
+ const shop = company["fluid_shop"];
41
+ const companyId = company["id"] ?? company["fluid_company_id"] ?? context["company_id"];
42
+ return {
43
+ dri: typeof dri === "string" ? dri : void 0,
44
+ fluidShop: typeof shop === "string" ? shop : void 0,
45
+ companyId: typeof companyId === "string" || typeof companyId === "number" ? companyId : void 0
46
+ };
47
+ };
48
+ function withFluidWebhook(config, handler) {
49
+ const { resolve, bootstrapSecret, bootstrapEvents = [INSTALL_EVENT], logger = consoleLogger, name = "webhook", onAuthFailure = () => Response.json({ error: "unauthorized" }, { status: 401 }), onInvalidBody = () => Response.json({ error: "invalid request" }, { status: 400 }), onResolveError = () => Response.json({ error: "resolver unavailable" }, { status: 503 }), onHandlerError = () => Response.json({ error: "internal error" }, { status: 500 }) } = config;
50
+ return async function POST(request) {
51
+ const log = (level, message, context = {}) => logger[level](`[fluid-webhook:${name}] ${message}`, context);
52
+ let rawBody;
53
+ try {
54
+ rawBody = await request.text();
55
+ } catch (error) {
56
+ log("error", "could not read request body", describeError(error));
57
+ return onInvalidBody("unreadable");
58
+ }
59
+ let payload;
60
+ try {
61
+ payload = JSON.parse(rawBody);
62
+ } catch {
63
+ log("warn", "body was not valid JSON");
64
+ return onInvalidBody("malformed_json");
65
+ }
66
+ const event = eventOf(payload);
67
+ const signature = request.headers.get(HEADER_SIGNATURE);
68
+ const timestamp = request.headers.get(HEADER_TIMESTAMP);
69
+ let principal = null;
70
+ let authFailure = null;
71
+ let resolveThrew = false;
72
+ let resolveError;
73
+ const resolved = await resolve({
74
+ payload,
75
+ headers: request.headers,
76
+ ...readHints(payload),
77
+ fluidShop: request.headers.get("x-fluid-shop") ?? readHints(payload).fluidShop
78
+ }).catch((error) => {
79
+ resolveThrew = true;
80
+ resolveError = error;
81
+ log("error", "resolve threw", describeError(error));
82
+ return null;
83
+ });
84
+ if (resolveThrew) {
85
+ log("warn", "unavailable", {
86
+ reason: "resolve_failed",
87
+ event
88
+ });
89
+ return onResolveError(resolveError);
90
+ }
91
+ const candidates = [];
92
+ const isBootstrapEvent = bootstrapEvents.includes(event);
93
+ if (isBootstrapEvent) {
94
+ if (bootstrapSecret) candidates.push({
95
+ label: "bootstrap",
96
+ secret: bootstrapSecret
97
+ });
98
+ } else if (resolved?.secret) candidates.push({
99
+ label: "company",
100
+ secret: resolved.secret
101
+ });
102
+ if (candidates.length === 0) authFailure = isBootstrapEvent ? "no_bootstrap_secret" : resolved ? "blank_secret" : "unresolved_company";
103
+ else {
104
+ let matched = null;
105
+ let lastReason = "mismatch";
106
+ for (const candidate of candidates) {
107
+ const result = verifySignature({
108
+ rawBody,
109
+ signature,
110
+ timestamp,
111
+ secret: candidate.secret
112
+ });
113
+ if (result.valid) {
114
+ matched = candidate.label;
115
+ break;
116
+ }
117
+ lastReason = result.reason;
118
+ }
119
+ if (matched === "company" && resolved) principal = resolved.principal;
120
+ else if (matched === "bootstrap") principal = resolved?.principal ?? null;
121
+ else authFailure = lastReason;
122
+ }
123
+ if (authFailure) {
124
+ log("warn", "rejected", {
125
+ reason: authFailure,
126
+ event
127
+ });
128
+ return onAuthFailure(authFailure);
129
+ }
130
+ try {
131
+ const result = await handler({
132
+ event,
133
+ payload,
134
+ principal,
135
+ headers: request.headers,
136
+ signal: request.signal,
137
+ rawBody
138
+ });
139
+ return result instanceof Response ? result : Response.json(result);
140
+ } catch (error) {
141
+ log("error", "handler threw", {
142
+ event,
143
+ ...describeError(error)
144
+ });
145
+ return onHandlerError(error);
146
+ }
147
+ };
148
+ }
149
+ //#endregion
150
+ export { INSTALL_EVENT, effectivePayload, withFluidWebhook };
@@ -0,0 +1,3 @@
1
+ import { CallbackContext, CallbackFailure, CallbackHandler, WithFluidCallbackConfig, withFluidCallback } from "./next/callbacks.mjs";
2
+ import { INSTALL_EVENT, ResolvedWebhookPrincipal, WebhookContext, WebhookHandler, WebhookRoutingHints, WithFluidWebhookConfig, effectivePayload, withFluidWebhook } from "./next/webhooks.mjs";
3
+ export { type CallbackContext, type CallbackFailure, type CallbackHandler, INSTALL_EVENT, type ResolvedWebhookPrincipal, type WebhookContext, type WebhookHandler, type WebhookRoutingHints, type WithFluidCallbackConfig, type WithFluidWebhookConfig, effectivePayload, withFluidCallback, withFluidWebhook };
package/dist/next.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { withFluidCallback } from "./next/callbacks.mjs";
2
+ import { INSTALL_EVENT, effectivePayload, withFluidWebhook } from "./next/webhooks.mjs";
3
+ export { INSTALL_EVENT, effectivePayload, withFluidCallback, withFluidWebhook };
@@ -0,0 +1,2 @@
1
+ import { PrismaCallbackDelegate, createPrismaCallbackStore } from "./store/prisma.mjs";
2
+ export { type PrismaCallbackDelegate, createPrismaCallbackStore };
@@ -0,0 +1,2 @@
1
+ import { createPrismaCallbackStore } from "./store/prisma.mjs";
2
+ export { createPrismaCallbackStore };
@@ -0,0 +1,32 @@
1
+ //#region src/readiness.d.ts
2
+ /**
3
+ * Boot-time check that this droplet can verify callbacks.
4
+ *
5
+ * Reports whether any registration tokens are stored yet. Until they are, a
6
+ * wrapped route cannot verify anything it receives.
7
+ *
8
+ * Never throws and never prevents boot.
9
+ *
10
+ * CALLING CONTRACT: do not await this from `instrumentation.register()`. It
11
+ * retries for minutes rather than seconds before giving up, so awaiting it
12
+ * would hold a cold start open for that whole span.
13
+ */
14
+ type CallbackVerificationReadiness = "ready" | "no-registrations" | "unavailable";
15
+ type Logger = Pick<Console, "error" | "info">;
16
+ declare function reportCallbackVerificationReadiness({
17
+ countRegistrations,
18
+ backfillCommand,
19
+ logger
20
+ }: {
21
+ countRegistrations: () => Promise<number>;
22
+ /**
23
+ * The exact command an operator should run to fix an empty table, e.g.
24
+ * `pnpm --filter <droplet> backfill:callbacks`. Named rather than derived,
25
+ * because the alert is read by someone who does not already know which
26
+ * droplet is broken.
27
+ */
28
+ backfillCommand: string;
29
+ logger?: Logger;
30
+ }): Promise<CallbackVerificationReadiness>;
31
+ //#endregion
32
+ export { CallbackVerificationReadiness, reportCallbackVerificationReadiness };
@@ -0,0 +1,53 @@
1
+ //#region src/readiness.ts
2
+ const MARKER = "[fluid-callback:readiness]";
3
+ const TIMEOUT_MS = 2e4;
4
+ const RETRY_DELAYS_MS = [
5
+ 3e4,
6
+ 6e4,
7
+ 12e4
8
+ ];
9
+ async function withTimeout(work, ms) {
10
+ let timer;
11
+ try {
12
+ return await Promise.race([work, new Promise((_, reject) => {
13
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
14
+ })]);
15
+ } finally {
16
+ if (timer) clearTimeout(timer);
17
+ }
18
+ }
19
+ function sleep(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+ async function reportCallbackVerificationReadiness({ countRegistrations, backfillCommand, logger = console }) {
23
+ const attempts = RETRY_DELAYS_MS.length + 1;
24
+ let lastError = "";
25
+ for (let attempt = 0; attempt < attempts; attempt++) {
26
+ if (attempt > 0) await sleep(RETRY_DELAYS_MS[attempt - 1]);
27
+ let count;
28
+ try {
29
+ count = await withTimeout(countRegistrations(), TIMEOUT_MS);
30
+ } catch (error) {
31
+ lastError = error instanceof Error ? error.message : String(error);
32
+ logger.info(`${MARKER} could not read stored callback registrations yet; retrying`, {
33
+ attempt: attempt + 1,
34
+ of: attempts,
35
+ error: lastError
36
+ });
37
+ continue;
38
+ }
39
+ if (count === 0) {
40
+ logger.error(`${MARKER} NO callback registrations are stored, but callback verification is enabled. Every callback Fluid sends will be refused, and refusals answer 200 so nothing else will report this. Run \`${backfillCommand}\` against this database.`);
41
+ return "no-registrations";
42
+ }
43
+ logger.info(`${MARKER} verifying callbacks against stored registrations`, {
44
+ registrations: count,
45
+ ...attempt > 0 ? { afterAttempts: attempt + 1 } : {}
46
+ });
47
+ return "ready";
48
+ }
49
+ logger.error(`${MARKER} could not check stored callback registrations after ${attempts} attempts; cannot tell whether verification will accept anything`, { error: lastError });
50
+ return "unavailable";
51
+ }
52
+ //#endregion
53
+ export { reportCallbackVerificationReadiness };
@@ -0,0 +1,31 @@
1
+ //#region src/redact.d.ts
2
+ /**
3
+ * Recursively replaces credential-shaped values.
4
+ *
5
+ * Deny-by-name rather than deny-by-value: matching on the key is stable, while
6
+ * matching on the value is how redaction helpers silently stop working (a
7
+ * helper testing values for the substring "password" never fires on a license
8
+ * key).
9
+ */
10
+ declare function redactValue(input: unknown, depth?: number): unknown;
11
+ /**
12
+ * Renders an error for logging without leaking a body through its message.
13
+ *
14
+ * Truncation alone is not enough: upstream clients routinely embed whole
15
+ * response bodies in error messages, and the first 300 characters of a cart
16
+ * payload is still a cart payload. Anything resembling a JSON object or a
17
+ * credential is stripped before truncating.
18
+ */
19
+ declare function describeError(error: unknown): {
20
+ name: string;
21
+ message: string;
22
+ };
23
+ interface Logger {
24
+ info(message: string, context?: Record<string, unknown>): void;
25
+ warn(message: string, context?: Record<string, unknown>): void;
26
+ error(message: string, context?: Record<string, unknown>): void;
27
+ }
28
+ /** Injectable so droplets can route into Sentry or a structured logger later. */
29
+ declare const consoleLogger: Logger;
30
+ //#endregion
31
+ export { Logger, consoleLogger, describeError, redactValue };
@@ -0,0 +1,35 @@
1
+ //#region src/redact.ts
2
+ const SENSITIVE_KEY_PATTERN = /(token|secret|password|credential|api[_-]?key|authorization|signature)/i;
3
+ const REDACTED = "[REDACTED]";
4
+ const MAX_DEPTH = 6;
5
+ function redactValue(input, depth = 0) {
6
+ if (depth > MAX_DEPTH) return "[TRUNCATED]";
7
+ if (input === null || input === void 0) return input;
8
+ if (Array.isArray(input)) return input.map((entry) => redactValue(entry, depth + 1));
9
+ if (typeof input === "object") {
10
+ const output = {};
11
+ for (const [key, value] of Object.entries(input)) output[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : redactValue(value, depth + 1);
12
+ return output;
13
+ }
14
+ return input;
15
+ }
16
+ function describeError(error) {
17
+ const raw = error instanceof Error ? error.message : String(error);
18
+ return {
19
+ name: error instanceof Error ? error.name : "UnknownError",
20
+ message: truncate(scrubMessage(raw), 300)
21
+ };
22
+ }
23
+ function scrubMessage(message) {
24
+ return message.replace(/[{[][\s\S]*[}\]]/g, "[STRUCTURED_CONTENT_REMOVED]").replace(/\b(?:cvt|wvt|dit|dri|drp|cbr)_[A-Za-z0-9._-]+/g, "[REDACTED]").replace(/\b(token|secret|password|credential|api[_-]?key|authorization|signature)\b\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
25
+ }
26
+ function truncate(value, max) {
27
+ return value.length <= max ? value : `${value.slice(0, max)}…[truncated]`;
28
+ }
29
+ const consoleLogger = {
30
+ info: (message, context) => console.log(message, context ?? {}),
31
+ warn: (message, context) => console.warn(message, context ?? {}),
32
+ error: (message, context) => console.error(message, context ?? {})
33
+ };
34
+ //#endregion
35
+ export { consoleLogger, describeError, redactValue };
@@ -0,0 +1,59 @@
1
+ //#region src/signatures.d.ts
2
+ /**
3
+ * Maximum age of a signature, in seconds.
4
+ *
5
+ * Note this bounds *freshness*, not replay: the same request can be presented
6
+ * repeatedly inside the window. There is no nonce or seen-signature cache.
7
+ */
8
+ declare const MAX_SIGNATURE_AGE_SECONDS = 300;
9
+ type SignatureFailureReason = "missing_signature" | "missing_timestamp" | "malformed_timestamp" | "stale_timestamp" | "malformed_signature" | "no_secret" | "mismatch";
10
+ type SignatureResult = {
11
+ valid: true;
12
+ } | {
13
+ valid: false;
14
+ reason: SignatureFailureReason;
15
+ detail: string;
16
+ };
17
+ interface VerifySignatureInput {
18
+ /**
19
+ * The exact bytes that were signed. Never a re-serialised object.
20
+ *
21
+ * Prefer the byte form. A string has already been through a UTF-8 decode,
22
+ * which is not a round trip: `Request.text()` strips a leading BOM and turns
23
+ * malformed sequences into U+FFFD, so re-encoding it here would not reproduce
24
+ * what Fluid actually hashed.
25
+ */
26
+ rawBody: string | Uint8Array;
27
+ signature: string | null;
28
+ timestamp: string | null;
29
+ /** The HMAC key. For callbacks this is the presented token; for webhooks the stored per-company token. */
30
+ secret: string;
31
+ maxAgeSeconds?: number;
32
+ /** Injectable for tests. Seconds since epoch. */
33
+ now?: () => number;
34
+ }
35
+ /**
36
+ * Verifies an HMAC-SHA256 signature over `{timestamp}.{rawBody}`.
37
+ *
38
+ * This is the same computation Fluid performs for both webhooks
39
+ * (`WebhookCaller`) and callbacks (`Callback::Client#generate_signed_headers`).
40
+ * The two paths differ only in where the secret comes from.
41
+ */
42
+ declare function verifySignature({
43
+ rawBody,
44
+ signature,
45
+ timestamp,
46
+ secret,
47
+ maxAgeSeconds,
48
+ now
49
+ }: VerifySignatureInput): SignatureResult;
50
+ /**
51
+ * Hashes a callback verification token for storage.
52
+ *
53
+ * Tokens are stored as digests so that a dump of a droplet's database does not
54
+ * hand over working callback credentials. Fluid presents the plaintext token on
55
+ * every request, so the digest is sufficient to locate the registration.
56
+ */
57
+ declare function tokenDigest(token: string): string;
58
+ //#endregion
59
+ export { MAX_SIGNATURE_AGE_SECONDS, SignatureFailureReason, SignatureResult, VerifySignatureInput, tokenDigest, verifySignature };
@@ -0,0 +1,60 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ //#region src/signatures.ts
3
+ const MAX_SIGNATURE_AGE_SECONDS = 300;
4
+ function verifySignature({ rawBody, signature, timestamp, secret, maxAgeSeconds = 300, now = () => Math.floor(Date.now() / 1e3) }) {
5
+ if (!signature) return {
6
+ valid: false,
7
+ reason: "missing_signature",
8
+ detail: "Missing X-Fluid-Signature header"
9
+ };
10
+ if (!timestamp) return {
11
+ valid: false,
12
+ reason: "missing_timestamp",
13
+ detail: "Missing X-Fluid-Timestamp header"
14
+ };
15
+ const timestampSeconds = timestamp.trim() === "" ? NaN : Number(timestamp);
16
+ if (!Number.isInteger(timestampSeconds)) return {
17
+ valid: false,
18
+ reason: "malformed_timestamp",
19
+ detail: "X-Fluid-Timestamp is not an integer"
20
+ };
21
+ const age = Math.abs(now() - timestampSeconds);
22
+ if (age > maxAgeSeconds) return {
23
+ valid: false,
24
+ reason: "stale_timestamp",
25
+ detail: `Signature age ${age}s exceeds ${maxAgeSeconds}s`
26
+ };
27
+ if (!secret) return {
28
+ valid: false,
29
+ reason: "no_secret",
30
+ detail: "No secret available to verify against"
31
+ };
32
+ const bodyBytes = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
33
+ const expected = createHmac("sha256", secret).update(Buffer.from(`${timestamp}.`, "utf8")).update(bodyBytes).digest("hex");
34
+ if (!isHexOfLength(signature, expected.length)) return {
35
+ valid: false,
36
+ reason: "malformed_signature",
37
+ detail: "Signature is not hex of the expected length"
38
+ };
39
+ const presented = Buffer.from(signature, "hex");
40
+ const computed = Buffer.from(expected, "hex");
41
+ if (presented.length !== computed.length || presented.length === 0) return {
42
+ valid: false,
43
+ reason: "mismatch",
44
+ detail: "Signature mismatch"
45
+ };
46
+ if (!timingSafeEqual(presented, computed)) return {
47
+ valid: false,
48
+ reason: "mismatch",
49
+ detail: "Signature mismatch"
50
+ };
51
+ return { valid: true };
52
+ }
53
+ function isHexOfLength(value, length) {
54
+ return value.length === length && /^[0-9a-fA-F]+$/.test(value);
55
+ }
56
+ function tokenDigest(token) {
57
+ return createHash("sha256").update(token).digest("hex");
58
+ }
59
+ //#endregion
60
+ export { MAX_SIGNATURE_AGE_SECONDS, tokenDigest, verifySignature };