@openreceive/fastify 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenReceive contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @openreceive/fastify
2
+
3
+ Fastify adapter for `@openreceive/http`.
4
+
5
+ This package is ESM-only and requires Node >= 22.
6
+
7
+ The all-in-one form is the happy path: register the plugin with the order
8
+ hooks and a database handle, and it builds the service and host itself.
9
+
10
+ ```ts
11
+ import { openReceiveFastify } from "@openreceive/fastify";
12
+
13
+ await fastify.register(openReceiveFastify, {
14
+ wallet: { nwc: process.env.NWC_URI! }, // receive-only; your app refuses to start otherwise
15
+ storage: {
16
+ db, // pg Pool/Client, node:sqlite, better-sqlite3, or a custom adapter
17
+ onPaid: async ({ reference, query }) => {
18
+ // Host SQL reaches your driver VERBATIM: `?` on sqlite as shown,
19
+ // `$1` on postgres. Nothing rewrites placeholders.
20
+ await query("UPDATE orders SET state = 'paid' WHERE id = ?", [reference]);
21
+ },
22
+ },
23
+ amountFor: (reference) => orders.find(reference)?.amount ?? null, // null → 404
24
+ authorize: ({ resource }) => orders.viewerOwns(resource.reference),
25
+ prefix: "/openreceive",
26
+ });
27
+ ```
28
+
29
+ `onPaid` runs inside the settlement transaction, only for the order's first
30
+ settled attempt. Do the order update (or insert an outbox row) through the
31
+ supplied `query`: a plain ORM call commits on its own connection, so it would
32
+ survive a rolled-back settlement — settlement side effects belong on `query`.
33
+ Delivery is at-least-once and retried until `onPaid` succeeds, so make it
34
+ idempotent, and keep it to database writes: an email or webhook sent from here
35
+ survives a rolled-back settlement and goes out again on the retry. Flag the
36
+ order and drain it from your own worker after commit.
37
+
38
+ The library-owned repository commits a payment-attempt row in the host's
39
+ existing database before payer instructions are returned, and settlement
40
+ piggybacks on the mounted routes by default through the durable
41
+ `openreceive_meta` gate (`opportunisticReconcile` disables or tunes it);
42
+ `startNotificationWorker` is the optional worker process. Behind a
43
+ reverse proxy, the `trustProxyIpHeader` option attributes `rateLimiting`
44
+ client IPs from a proxy-set header. OpenReceive never requires a separate
45
+ database or Redis.
46
+
47
+ ## Advanced: composed form
48
+
49
+ Construct the pieces yourself (shared service, custom repository, tests) and
50
+ pass them in. `createHost` is the persistence step: it owns the
51
+ `openreceive_payments` rows — per-reference commit locking, write-once settlement,
52
+ and the reconciliation state machine.
53
+
54
+ Composing needs `@openreceive/http` and `@openreceive/node` as direct
55
+ dependencies of your app — they are transitive dependencies of this adapter, so
56
+ under pnpm or any strict-resolution install, importing them without adding them
57
+ fails:
58
+
59
+ ```sh
60
+ npm install @openreceive/http @openreceive/node
61
+ ```
62
+
63
+ ```ts
64
+ import { openReceiveFastify } from "@openreceive/fastify";
65
+ import { createHost } from "@openreceive/http";
66
+ import { createOpenReceive } from "@openreceive/node";
67
+
68
+ const service = await createOpenReceive(); // reads NWC_URI
69
+
70
+ const host = createHost({
71
+ db,
72
+ amountFor: (reference) => orders.find(reference)?.amount ?? null, // null → 404
73
+ onPaid: async ({ reference, query }) => {
74
+ await query("UPDATE orders SET state = 'paid' WHERE id = ?", [reference]);
75
+ },
76
+ });
77
+
78
+ await fastify.register(openReceiveFastify, { service, authorize, host, prefix: "/openreceive" });
79
+ ```
80
+
81
+ This package re-exports only the curated `@openreceive/http` surface: the
82
+ handler/stack factories, the error surface, the notification worker, the
83
+ options/context/hook types, and the generated `Wire*` wire body
84
+ types. Host-integration internals — `createHost`, the SQL payment
85
+ repository, the reconcile gate, the rate-limit helpers — live only in
86
+ `@openreceive/http`; import them from there when composing your own host
87
+ (`npm run check:public-api` pins both surfaces).
@@ -0,0 +1,60 @@
1
+ import { CreateHttpHandlerOptions, CreateStackOptions } from '@openreceive/http';
2
+ export * from '@openreceive/http/adapter-surface';
3
+
4
+ /** Minimal structural view of the Fastify surface this adapter uses. */
5
+ interface FastifyRequestLike {
6
+ readonly method: string;
7
+ readonly headers: Record<string, string | string[] | undefined>;
8
+ readonly body?: unknown;
9
+ readonly raw: {
10
+ url?: string;
11
+ };
12
+ /** Scheme, honoring Fastify's trustProxy setting (https behind TLS/proxy). */
13
+ readonly protocol?: string;
14
+ }
15
+ interface FastifyReplyLike {
16
+ code(statusCode: number): FastifyReplyLike;
17
+ header(key: string, value: string): FastifyReplyLike;
18
+ send(payload: string): unknown;
19
+ /** Invokes the app's not-found handler for paths this plugin does not own. */
20
+ callNotFound?(): unknown;
21
+ }
22
+ interface FastifyInstanceLike {
23
+ all(path: string, handler: (request: FastifyRequestLike, reply: FastifyReplyLike) => Promise<unknown>): unknown;
24
+ /** Fastify lifecycle hook; used to close an all-in-one stack with the app. */
25
+ addHook?(name: "onClose", hook: () => Promise<void>): unknown;
26
+ /** Accumulated register prefix for this instance (from register's `{ prefix }`). */
27
+ readonly prefix?: string;
28
+ }
29
+ interface FastifyAdapterExtras {
30
+ /**
31
+ * Opt-in client-IP attribution for `rateLimiting` behind a reverse proxy:
32
+ * by default the limiter reads `request.ip`, which is the proxy's address
33
+ * when Fastify's trustProxy is not configured — every payer would then share
34
+ * one budget. Only safe when YOUR reverse proxy sets the header (a
35
+ * direct-to-origin client can forge it). `true` reads the first hop of
36
+ * `x-forwarded-for`; a string names another header (e.g. `"cf-connecting-ip"`).
37
+ */
38
+ readonly trustProxyIpHeader?: boolean | string;
39
+ }
40
+ interface FastifyHandlerOptions extends CreateHttpHandlerOptions, FastifyAdapterExtras {
41
+ }
42
+ /** All-in-one form: host hooks + `wallet` + `storage`; the plugin builds service and host. */
43
+ interface FastifyStackOptions extends CreateStackOptions, FastifyAdapterExtras {
44
+ }
45
+ /**
46
+ * Two forms: the all-in-one happy path (host hooks + `wallet` + `storage`; the
47
+ * plugin builds the service and host and closes the owned service on app close
48
+ * — no background process, settlement is opportunistic) or the composed
49
+ * `{ service, host, authorize }` form.
50
+ */
51
+ type FastifyOptions = FastifyHandlerOptions | FastifyStackOptions;
52
+ /** Fastify plugin serving the OpenReceive routes. Register it with a `prefix`. */
53
+ declare function openReceiveFastify(fastify: FastifyInstanceLike, options: FastifyOptions, done?: (error?: Error) => void): void;
54
+ /**
55
+ * Map a host/service error onto a Fastify JSON reply.
56
+ * Returns `true` when handled; `false` when the caller should rethrow.
57
+ */
58
+ declare function sendHostRouteError(reply: FastifyReplyLike, error: unknown): boolean;
59
+
60
+ export { type FastifyHandlerOptions, type FastifyInstanceLike, type FastifyOptions, type FastifyReplyLike, type FastifyRequestLike, type FastifyStackOptions, openReceiveFastify, sendHostRouteError };
package/dist/index.js ADDED
@@ -0,0 +1,103 @@
1
+ // src/index.ts
2
+ import {
3
+ createHttpHandler,
4
+ createStack,
5
+ createProxyRateLimitingConfig,
6
+ createRequestId,
7
+ errorResponse,
8
+ isStackOptions,
9
+ mapHostRouteError,
10
+ HttpError,
11
+ isUnderPrefix,
12
+ webRequest
13
+ } from "@openreceive/http";
14
+ export * from "@openreceive/http/adapter-surface";
15
+ function openReceiveFastify(fastify, options, done) {
16
+ const instancePrefix = normalizeFastifyPrefix(fastify.prefix);
17
+ const effectivePrefix = resolveHandlerPrefix(instancePrefix, options.prefix);
18
+ let handler;
19
+ if (isStackOptions(options)) {
20
+ const { trustProxyIpHeader, ...stackOptions } = options;
21
+ const stack = createStack({
22
+ ...stackOptions,
23
+ ...effectivePrefix,
24
+ ...createProxyRateLimitingConfig(stackOptions.rateLimiting, trustProxyIpHeader)
25
+ });
26
+ handler = stack.handler;
27
+ fastify.addHook?.("onClose", () => stack.close());
28
+ } else {
29
+ const { trustProxyIpHeader, ...handlerOptions } = options;
30
+ handler = createHttpHandler({
31
+ ...handlerOptions,
32
+ ...effectivePrefix,
33
+ ...createProxyRateLimitingConfig(handlerOptions.rateLimiting, trustProxyIpHeader)
34
+ });
35
+ }
36
+ fastify.all("/*", async (request, reply) => {
37
+ const relativeUrl = stripInstancePrefix(request.raw.url ?? "/", instancePrefix);
38
+ const pathname = relativeUrl.split("?")[0];
39
+ if (!isUnderPrefix(pathname, handler.prefix) && reply.callNotFound !== void 0) {
40
+ return reply.callNotFound();
41
+ }
42
+ const response = await respond(handler, request, relativeUrl);
43
+ reply.code(response.status);
44
+ response.headers.forEach((value, key) => {
45
+ reply.header(key, value);
46
+ });
47
+ return reply.send(await response.text());
48
+ });
49
+ done?.();
50
+ }
51
+ function sendHostRouteError(reply, error) {
52
+ const mapped = mapHostRouteError(error);
53
+ if (mapped === null) return false;
54
+ reply.code(mapped.status).header("content-type", "application/json; charset=utf-8").send(JSON.stringify(mapped.body));
55
+ return true;
56
+ }
57
+ function resolveHandlerPrefix(instancePrefix, optionsPrefix) {
58
+ if (instancePrefix === "") return {};
59
+ const registerPrefix = normalizeFastifyPrefix(optionsPrefix);
60
+ if (registerPrefix === "") return {};
61
+ if (instancePrefix.endsWith(registerPrefix)) return { prefix: "/" };
62
+ throw new TypeError(
63
+ `openReceiveFastify got prefix "${registerPrefix}" but the instance is registered under "${instancePrefix}". Pass the prefix at register() \u2014 fastify.register(openReceiveFastify, { prefix, ... }) \u2014 so the route scope and the handler agree.`
64
+ );
65
+ }
66
+ function stripInstancePrefix(url, instancePrefix) {
67
+ if (instancePrefix === "") return url;
68
+ if (url === instancePrefix) return "/";
69
+ if (url.startsWith(`${instancePrefix}/`)) return url.slice(instancePrefix.length);
70
+ if (url.startsWith(`${instancePrefix}?`)) return `/${url.slice(instancePrefix.length)}`;
71
+ throw new Error(
72
+ `openReceiveFastify is registered under prefix "${instancePrefix}" but received a request for "${url}". Register the plugin on the instance that serves these routes, or pass the prefix at register() so Fastify scopes the routes to it.`
73
+ );
74
+ }
75
+ function normalizeFastifyPrefix(prefix) {
76
+ if (prefix === void 0) return "";
77
+ const trimmed = prefix.trim();
78
+ if (trimmed === "") return "";
79
+ const value = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
80
+ const withoutTrailing = value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value;
81
+ return withoutTrailing === "/" ? "" : withoutTrailing;
82
+ }
83
+ async function respond(handler, request, url) {
84
+ try {
85
+ return await handler(toWebRequest(request, url), { native: request });
86
+ } catch (error) {
87
+ if (error instanceof HttpError) return errorResponse(error, createRequestId());
88
+ throw error;
89
+ }
90
+ }
91
+ function toWebRequest(request, url) {
92
+ return webRequest({
93
+ method: request.method,
94
+ headers: request.headers,
95
+ url,
96
+ protocol: request.protocol,
97
+ parsedBody: request.body
98
+ });
99
+ }
100
+ export {
101
+ openReceiveFastify,
102
+ sendHostRouteError
103
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@openreceive/fastify",
3
+ "version": "0.2.1",
4
+ "description": "Fastify plugin for the OpenReceive checkout HTTP handler.",
5
+ "keywords": [
6
+ "bitcoin",
7
+ "lightning",
8
+ "payments",
9
+ "nwc",
10
+ "bolt11",
11
+ "openreceive",
12
+ "fastify",
13
+ "checkout"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "dependencies": {
20
+ "@openreceive/http": "0.2.1"
21
+ },
22
+ "peerDependencies": {
23
+ "fastify": "^4.0.0 || ^5.0.0"
24
+ },
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsup src/index.ts --format esm --dts --clean --target es2022 --out-dir dist",
38
+ "prepack": "npm run build"
39
+ },
40
+ "license": "MIT",
41
+ "author": "OpenReceive <info@openreceive.org>",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/openreceive/openreceive.git",
45
+ "directory": "packages/js/fastify"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/openreceive/openreceive/issues"
49
+ },
50
+ "homepage": "https://openreceive.org",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ }
57
+ }