@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
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 };
|
package/dist/drizzle.mjs
ADDED
package/dist/index.d.mts
ADDED
|
@@ -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 };
|