@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 +184 -0
- package/dist/backfill.d.mts +108 -0
- package/dist/backfill.mjs +112 -0
- package/dist/checkout.d.mts +41 -0
- package/dist/checkout.mjs +98 -0
- package/dist/drizzle.d.mts +2 -0
- package/dist/drizzle.mjs +2 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +5 -0
- package/dist/next/callbacks.d.mts +81 -0
- package/dist/next/callbacks.mjs +120 -0
- package/dist/next/webhooks.d.mts +119 -0
- package/dist/next/webhooks.mjs +150 -0
- package/dist/next.d.mts +3 -0
- package/dist/next.mjs +3 -0
- package/dist/prisma.d.mts +2 -0
- package/dist/prisma.mjs +2 -0
- package/dist/readiness.d.mts +32 -0
- package/dist/readiness.mjs +53 -0
- package/dist/redact.d.mts +31 -0
- package/dist/redact.mjs +35 -0
- package/dist/signatures.d.mts +59 -0
- package/dist/signatures.mjs +60 -0
- package/dist/store/drizzle.d.mts +50 -0
- package/dist/store/drizzle.mjs +20 -0
- package/dist/store/prisma.d.mts +46 -0
- package/dist/store/prisma.mjs +21 -0
- package/dist/store/types.d.mts +26 -0
- package/package.json +75 -0
- package/schema/callback-registrations.prisma +21 -0
|
@@ -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 };
|
package/dist/next.d.mts
ADDED
|
@@ -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
package/dist/prisma.mjs
ADDED
|
@@ -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 };
|
package/dist/redact.mjs
ADDED
|
@@ -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 };
|