thunder-bridge 1.3.0 → 1.4.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/README.md CHANGED
@@ -34,10 +34,16 @@ A page can run the whole flow with no backend of its own. The gateway answers
34
34
  every origin, and coinos, Alby and Stacker News serve their LNURL endpoints with
35
35
  CORS open, so the proof fetches work from a browser too.
36
36
 
37
+ The url below is a shared demo that answers anyone and forgets everything on
38
+ restart. It is there so this snippet runs as written. It is a base url rather than a
39
+ page, so opening it in a browser gives a `404` and
40
+ [`/health`](https://public.thunder-bridge.agora.gripe/health) is what tells you it is
41
+ up. Point production at a gateway of your own, which takes one command.
42
+
37
43
  ```ts
38
44
  import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
39
45
 
40
- const gateway = new ThunderBridge("https://thunder-bridge-production.up.railway.app");
46
+ const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
41
47
 
42
48
  const request: CreatePaymentParams = {
43
49
  lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
@@ -92,6 +98,7 @@ them and this table does not repeat them.
92
98
  | `gateway.firstToSettle(ids, options?)` | wait on several legs, keep the first really paid, drop the losers |
93
99
  | `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, without the address or the amount |
94
100
  | `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own. `replay` asks for how many past settlements on connect |
101
+ | `gateway.createSocketTicket(params)` | a one minute pass onto one trigger's stream, to hand something that must not hold the secret |
95
102
  | `gateway.isPrivate` | whether a token was given |
96
103
 
97
104
  **Proving it** - [`src/verify.ts`](src/verify.ts)
@@ -109,6 +116,8 @@ them and this table does not repeat them.
109
116
  | Export | What it does |
110
117
  |---|---|
111
118
  | `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain. `replay` keeps its last settlements on the gateway for a page that opens later. From `thunder-bridge/server` |
119
+ | `watchTicketEndpoint(config)` | mints socket tickets for callers who already know the watch secret, 403 for the rest. From `thunder-bridge/server` |
120
+ | `publicWatchTicketEndpoint(config)` | the same for a board strangers are meant to read, minting for anyone. From `thunder-bridge/server` |
112
121
  | `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
113
122
  | `toLnurl(url)` | bech32-encode an endpoint url |
114
123
  | `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
@@ -117,6 +126,16 @@ them and this table does not repeat them.
117
126
  | `lightningVerifyEndpoint(config)` | the same shape for Lightning, asking the wallet on the gateway's behalf. From `thunder-bridge/server` |
118
127
  | `relayedVerifyUrl(mount, wallet, secret)` | the URL to hand the gateway instead of the wallet's, with the wallet's sealed inside |
119
128
 
129
+ There are two ticket endpoints rather than one taking a flag, so the call site
130
+ says which board this is. Mount one on its own path and POST to it from the page
131
+ before every connect, because a ticket lives a minute. Whichever you mount, the
132
+ page never holds the watch secret, which is the reason to mount either.
133
+
134
+ A public board is public in full: every viewer of that socket gets each
135
+ settlement's preimage, verify url and payment hash. Fine for a tip jar, wrong the
136
+ moment anything is gated behind those preimages, because then a viewer holds the
137
+ unlock.
138
+
120
139
  What your service answers once those handlers are mounted is written out in
121
140
  [`openapi.yaml`](openapi.yaml), shipped with this package.
122
141
 
@@ -167,9 +186,13 @@ BOLT12 offer is not handled, because this gateway never returns one.
167
186
  import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
168
187
 
169
188
  const toPay = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
170
- const tipJar = lnurlToSvg("https://agora.gripe/tip");
189
+ const tipJar = lnurlToSvg("https://thunder-bridge.agora.gripe/.well-known/lnurlp/21sats");
171
190
  ```
172
191
 
192
+ That second url is live and built with this library, a fixed 21 sat tip served by
193
+ `lnurlPayEndpoint`, so the QR it returns is one you can scan to see the whole flow
194
+ end to end.
195
+
173
196
  **Minting your own invoice** - [`src/rail.ts`](src/rail.ts): `invoiceFrom`, from
174
197
  `thunder-bridge/server`. A gateway that does not mint is one that never sees an
175
198
  address or an amount, so this is how a client gets a provable invoice itself and hands
package/dist/index.cjs CHANGED
@@ -897,6 +897,18 @@ function triggerEventFromWire(body) {
897
897
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
898
898
  };
899
899
  }
900
+ function socketTicketFromWire(body) {
901
+ const wire = asObject(body);
902
+ if (wire === null) {
903
+ return null;
904
+ }
905
+ const ticket = text(wire["ticket"]);
906
+ const expiresAt = secondsFrom(wire["expires_at"]);
907
+ if (ticket === null || expiresAt === null) {
908
+ return null;
909
+ }
910
+ return { ticket, expiresAt };
911
+ }
900
912
  function kindOf(wire) {
901
913
  const said = wire["kind"];
902
914
  if (said === "minted" || said === "watched") {
@@ -1920,10 +1932,26 @@ var ThunderBridge = class {
1920
1932
  socket?.close();
1921
1933
  };
1922
1934
  }
1935
+ /**
1936
+ * A one minute pass onto one trigger's stream, for something that must hold
1937
+ * neither the token nor the trigger secret. Mint it in a handler and answer
1938
+ * with the ticket alone, because that is all a browser needs to connect and
1939
+ * all it can do anything with. `watchTicketEndpoint` is this method already
1940
+ * wrapped in a route
1941
+ */
1942
+ async createSocketTicket(params) {
1943
+ return await this.mintedTicket({
1944
+ trigger_secret: unguessable(params.trigger),
1945
+ replay: params.replay
1946
+ });
1947
+ }
1923
1948
  needsTicket(asked) {
1924
1949
  return asked === true || this.token !== null;
1925
1950
  }
1926
1951
  async wsTicket(body) {
1952
+ return encodeURIComponent((await this.mintedTicket(body)).ticket);
1953
+ }
1954
+ async mintedTicket(body) {
1927
1955
  const sent = JSON.stringify(body);
1928
1956
  const response = await fetch(`${this.baseUrl}/ws-tickets`, {
1929
1957
  method: "POST",
@@ -1933,14 +1961,14 @@ var ThunderBridge = class {
1933
1961
  if (!response.ok) {
1934
1962
  throw await problemFrom(response);
1935
1963
  }
1936
- const minted = await response.json().catch(() => null);
1937
- if (typeof minted?.ticket !== "string") {
1964
+ const ticket = socketTicketFromWire(await response.json().catch(() => null));
1965
+ if (ticket === null) {
1938
1966
  throw new ProblemError({
1939
1967
  status: response.status,
1940
1968
  title: "The gateway answered with something that is not a ticket"
1941
1969
  });
1942
1970
  }
1943
- return encodeURIComponent(minted.ticket);
1971
+ return ticket;
1944
1972
  }
1945
1973
  async sending(path, body) {
1946
1974
  return { "content-type": "application/json", ...await this.reading("POST", path, body) };
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, W as WalletFailure, a as ThunderBridgeOptions, b as WatchPaymentParams, c as TriggerEvent, d as WaitOptions, P as Payment, C as CreatePaymentParams, S as Settlement } from './rail-DZzlN-bi.cjs';
2
- export { B as BankRailConfig, e as CreateOptions, f as CreateQuoteParams, F as FollowOptions, L as Leg, g as LightningRailConfig, O as Order, h as PaymentKind, i as PaymentStatus, Q as Quote, R as Rail, j as WalletReason, k as bankRail, l as lightningRail, t as toLnurl } from './rail-DZzlN-bi.cjs';
1
+ import { T as ThunderBridge, W as WalletFailure, a as ThunderBridgeOptions, b as WatchPaymentParams, c as TriggerEvent, d as WaitOptions, P as Payment, C as CreatePaymentParams, S as Settlement } from './rail-CqUfuYXJ.cjs';
2
+ export { B as BankRailConfig, e as CreateOptions, f as CreateQuoteParams, F as FollowOptions, L as Leg, g as LightningRailConfig, O as Order, h as PaymentKind, i as PaymentStatus, Q as Quote, R as Rail, j as SocketTicket, k as SocketTicketParams, l as WalletReason, m as bankRail, n as lightningRail, t as toLnurl } from './rail-CqUfuYXJ.cjs';
3
3
 
4
4
  /** What a BOLT11 invoice says about itself, every field null when it does not carry one */
5
5
  interface Invoice {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, W as WalletFailure, a as ThunderBridgeOptions, b as WatchPaymentParams, c as TriggerEvent, d as WaitOptions, P as Payment, C as CreatePaymentParams, S as Settlement } from './rail-DZzlN-bi.js';
2
- export { B as BankRailConfig, e as CreateOptions, f as CreateQuoteParams, F as FollowOptions, L as Leg, g as LightningRailConfig, O as Order, h as PaymentKind, i as PaymentStatus, Q as Quote, R as Rail, j as WalletReason, k as bankRail, l as lightningRail, t as toLnurl } from './rail-DZzlN-bi.js';
1
+ import { T as ThunderBridge, W as WalletFailure, a as ThunderBridgeOptions, b as WatchPaymentParams, c as TriggerEvent, d as WaitOptions, P as Payment, C as CreatePaymentParams, S as Settlement } from './rail-CqUfuYXJ.js';
2
+ export { B as BankRailConfig, e as CreateOptions, f as CreateQuoteParams, F as FollowOptions, L as Leg, g as LightningRailConfig, O as Order, h as PaymentKind, i as PaymentStatus, Q as Quote, R as Rail, j as SocketTicket, k as SocketTicketParams, l as WalletReason, m as bankRail, n as lightningRail, t as toLnurl } from './rail-CqUfuYXJ.js';
3
3
 
4
4
  /** What a BOLT11 invoice says about itself, every field null when it does not carry one */
5
5
  interface Invoice {
package/dist/index.js CHANGED
@@ -816,6 +816,18 @@ function triggerEventFromWire(body) {
816
816
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
817
817
  };
818
818
  }
819
+ function socketTicketFromWire(body) {
820
+ const wire = asObject(body);
821
+ if (wire === null) {
822
+ return null;
823
+ }
824
+ const ticket = text(wire["ticket"]);
825
+ const expiresAt = secondsFrom(wire["expires_at"]);
826
+ if (ticket === null || expiresAt === null) {
827
+ return null;
828
+ }
829
+ return { ticket, expiresAt };
830
+ }
819
831
  function kindOf(wire) {
820
832
  const said = wire["kind"];
821
833
  if (said === "minted" || said === "watched") {
@@ -1839,10 +1851,26 @@ var ThunderBridge = class {
1839
1851
  socket?.close();
1840
1852
  };
1841
1853
  }
1854
+ /**
1855
+ * A one minute pass onto one trigger's stream, for something that must hold
1856
+ * neither the token nor the trigger secret. Mint it in a handler and answer
1857
+ * with the ticket alone, because that is all a browser needs to connect and
1858
+ * all it can do anything with. `watchTicketEndpoint` is this method already
1859
+ * wrapped in a route
1860
+ */
1861
+ async createSocketTicket(params) {
1862
+ return await this.mintedTicket({
1863
+ trigger_secret: unguessable(params.trigger),
1864
+ replay: params.replay
1865
+ });
1866
+ }
1842
1867
  needsTicket(asked) {
1843
1868
  return asked === true || this.token !== null;
1844
1869
  }
1845
1870
  async wsTicket(body) {
1871
+ return encodeURIComponent((await this.mintedTicket(body)).ticket);
1872
+ }
1873
+ async mintedTicket(body) {
1846
1874
  const sent = JSON.stringify(body);
1847
1875
  const response = await fetch(`${this.baseUrl}/ws-tickets`, {
1848
1876
  method: "POST",
@@ -1852,14 +1880,14 @@ var ThunderBridge = class {
1852
1880
  if (!response.ok) {
1853
1881
  throw await problemFrom(response);
1854
1882
  }
1855
- const minted = await response.json().catch(() => null);
1856
- if (typeof minted?.ticket !== "string") {
1883
+ const ticket = socketTicketFromWire(await response.json().catch(() => null));
1884
+ if (ticket === null) {
1857
1885
  throw new ProblemError({
1858
1886
  status: response.status,
1859
1887
  title: "The gateway answered with something that is not a ticket"
1860
1888
  });
1861
1889
  }
1862
- return encodeURIComponent(minted.ticket);
1890
+ return ticket;
1863
1891
  }
1864
1892
  async sending(path, body) {
1865
1893
  return { "content-type": "application/json", ...await this.reading("POST", path, body) };
@@ -98,6 +98,23 @@ interface WatchPaymentParams {
98
98
  sealed?: string;
99
99
  webhookUrl?: string;
100
100
  }
101
+ /**
102
+ * A one minute pass onto one trigger's stream. It opens that trigger and nothing
103
+ * else, which is what makes it the thing to hand a browser when the trigger
104
+ * secret is not. `expiresAt` is unix seconds, like every other time here
105
+ */
106
+ interface SocketTicket {
107
+ ticket: string;
108
+ expiresAt: number;
109
+ }
110
+ /**
111
+ * Which trigger the ticket opens, and how many of its settlements the socket
112
+ * replays on connect, up to the ceiling the gateway's operator set
113
+ */
114
+ interface SocketTicketParams {
115
+ trigger: string;
116
+ replay?: number;
117
+ }
101
118
  /** Why one wallet in the list could not be used */
102
119
  type WalletReason = "address-unusable" | "unreachable" | "amount-not-accepted" | "cannot-prove-delivery" | "invoice-refused";
103
120
  interface WalletFailure {
@@ -326,8 +343,17 @@ declare class ThunderBridge {
326
343
  * is called. A trigger has no terminal state, so this never resolves
327
344
  */
328
345
  followTrigger(secret: string, options: FollowOptions): () => void;
346
+ /**
347
+ * A one minute pass onto one trigger's stream, for something that must hold
348
+ * neither the token nor the trigger secret. Mint it in a handler and answer
349
+ * with the ticket alone, because that is all a browser needs to connect and
350
+ * all it can do anything with. `watchTicketEndpoint` is this method already
351
+ * wrapped in a route
352
+ */
353
+ createSocketTicket(params: SocketTicketParams): Promise<SocketTicket>;
329
354
  private needsTicket;
330
355
  private wsTicket;
356
+ private mintedTicket;
331
357
  private sending;
332
358
  private reading;
333
359
  private speaking;
@@ -586,4 +612,4 @@ declare function nwcRail(config: NwcRailConfig): Rail;
586
612
  */
587
613
  declare function invoiceFrom(lnAddresses: string[], amountMsat: number): Promise<Resolved>;
588
614
 
589
- export { nwcSettlement as A, type BankRailConfig as B, type CreatePaymentParams as C, nwcVerifyEndpoint as D, nwcVerifyUrl as E, type FollowOptions as F, type Leg as L, type NwcConnection as N, type Order as O, type Payment as P, type Quote as Q, type Rail as R, type Settlement as S, ThunderBridge as T, type WalletFailure as W, type ThunderBridgeOptions as a, type WatchPaymentParams as b, type TriggerEvent as c, type WaitOptions as d, type CreateOptions as e, type CreateQuoteParams as f, type LightningRailConfig as g, type PaymentKind as h, type PaymentStatus as i, type WalletReason as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, type NwcInvoice as n, type NwcRailConfig as o, type NwcVerifyConfig as p, type Resolved as q, askWallet as r, blindLightningRail as s, toLnurl as t, invoiceFrom as u, nwcConnection as v, nwcHoldInvoice as w, nwcInvoice as x, nwcPay as y, nwcRail as z };
615
+ export { nwcPay as A, type BankRailConfig as B, type CreatePaymentParams as C, nwcRail as D, nwcSettlement as E, type FollowOptions as F, nwcVerifyEndpoint as G, nwcVerifyUrl as H, type Leg as L, type NwcConnection as N, type Order as O, type Payment as P, type Quote as Q, type Rail as R, type Settlement as S, ThunderBridge as T, type WalletFailure as W, type ThunderBridgeOptions as a, type WatchPaymentParams as b, type TriggerEvent as c, type WaitOptions as d, type CreateOptions as e, type CreateQuoteParams as f, type LightningRailConfig as g, type PaymentKind as h, type PaymentStatus as i, type SocketTicket as j, type SocketTicketParams as k, type WalletReason as l, bankRail as m, lightningRail as n, type BlindLightningRailConfig as o, type NwcInvoice as p, type NwcRailConfig as q, type NwcVerifyConfig as r, type Resolved as s, toLnurl as t, askWallet as u, blindLightningRail as v, invoiceFrom as w, nwcConnection as x, nwcHoldInvoice as y, nwcInvoice as z };
@@ -98,6 +98,23 @@ interface WatchPaymentParams {
98
98
  sealed?: string;
99
99
  webhookUrl?: string;
100
100
  }
101
+ /**
102
+ * A one minute pass onto one trigger's stream. It opens that trigger and nothing
103
+ * else, which is what makes it the thing to hand a browser when the trigger
104
+ * secret is not. `expiresAt` is unix seconds, like every other time here
105
+ */
106
+ interface SocketTicket {
107
+ ticket: string;
108
+ expiresAt: number;
109
+ }
110
+ /**
111
+ * Which trigger the ticket opens, and how many of its settlements the socket
112
+ * replays on connect, up to the ceiling the gateway's operator set
113
+ */
114
+ interface SocketTicketParams {
115
+ trigger: string;
116
+ replay?: number;
117
+ }
101
118
  /** Why one wallet in the list could not be used */
102
119
  type WalletReason = "address-unusable" | "unreachable" | "amount-not-accepted" | "cannot-prove-delivery" | "invoice-refused";
103
120
  interface WalletFailure {
@@ -326,8 +343,17 @@ declare class ThunderBridge {
326
343
  * is called. A trigger has no terminal state, so this never resolves
327
344
  */
328
345
  followTrigger(secret: string, options: FollowOptions): () => void;
346
+ /**
347
+ * A one minute pass onto one trigger's stream, for something that must hold
348
+ * neither the token nor the trigger secret. Mint it in a handler and answer
349
+ * with the ticket alone, because that is all a browser needs to connect and
350
+ * all it can do anything with. `watchTicketEndpoint` is this method already
351
+ * wrapped in a route
352
+ */
353
+ createSocketTicket(params: SocketTicketParams): Promise<SocketTicket>;
329
354
  private needsTicket;
330
355
  private wsTicket;
356
+ private mintedTicket;
331
357
  private sending;
332
358
  private reading;
333
359
  private speaking;
@@ -586,4 +612,4 @@ declare function nwcRail(config: NwcRailConfig): Rail;
586
612
  */
587
613
  declare function invoiceFrom(lnAddresses: string[], amountMsat: number): Promise<Resolved>;
588
614
 
589
- export { nwcSettlement as A, type BankRailConfig as B, type CreatePaymentParams as C, nwcVerifyEndpoint as D, nwcVerifyUrl as E, type FollowOptions as F, type Leg as L, type NwcConnection as N, type Order as O, type Payment as P, type Quote as Q, type Rail as R, type Settlement as S, ThunderBridge as T, type WalletFailure as W, type ThunderBridgeOptions as a, type WatchPaymentParams as b, type TriggerEvent as c, type WaitOptions as d, type CreateOptions as e, type CreateQuoteParams as f, type LightningRailConfig as g, type PaymentKind as h, type PaymentStatus as i, type WalletReason as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, type NwcInvoice as n, type NwcRailConfig as o, type NwcVerifyConfig as p, type Resolved as q, askWallet as r, blindLightningRail as s, toLnurl as t, invoiceFrom as u, nwcConnection as v, nwcHoldInvoice as w, nwcInvoice as x, nwcPay as y, nwcRail as z };
615
+ export { nwcPay as A, type BankRailConfig as B, type CreatePaymentParams as C, nwcRail as D, nwcSettlement as E, type FollowOptions as F, nwcVerifyEndpoint as G, nwcVerifyUrl as H, type Leg as L, type NwcConnection as N, type Order as O, type Payment as P, type Quote as Q, type Rail as R, type Settlement as S, ThunderBridge as T, type WalletFailure as W, type ThunderBridgeOptions as a, type WatchPaymentParams as b, type TriggerEvent as c, type WaitOptions as d, type CreateOptions as e, type CreateQuoteParams as f, type LightningRailConfig as g, type PaymentKind as h, type PaymentStatus as i, type SocketTicket as j, type SocketTicketParams as k, type WalletReason as l, bankRail as m, lightningRail as n, type BlindLightningRailConfig as o, type NwcInvoice as p, type NwcRailConfig as q, type NwcVerifyConfig as r, type Resolved as s, toLnurl as t, askWallet as u, blindLightningRail as v, invoiceFrom as w, nwcConnection as x, nwcHoldInvoice as y, nwcInvoice as z };
package/dist/server.cjs CHANGED
@@ -43,7 +43,9 @@ __export(server_exports, {
43
43
  nwcSettlement: () => nwcSettlement,
44
44
  nwcVerifyEndpoint: () => nwcVerifyEndpoint,
45
45
  nwcVerifyUrl: () => nwcVerifyUrl,
46
- relayedVerifyUrl: () => relayedVerifyUrl
46
+ publicWatchTicketEndpoint: () => publicWatchTicketEndpoint,
47
+ relayedVerifyUrl: () => relayedVerifyUrl,
48
+ watchTicketEndpoint: () => watchTicketEndpoint
47
49
  });
48
50
  module.exports = __toCommonJS(server_exports);
49
51
 
@@ -1447,6 +1449,18 @@ function lnurlPayEndpoint(config) {
1447
1449
  }
1448
1450
  };
1449
1451
  }
1452
+ function watchTicketEndpoint(config) {
1453
+ return async (request) => {
1454
+ const offered = await offeredSecret(request);
1455
+ if (!equalInConstantTime(sha256Hex(offered), sha256Hex(config.watchSecret))) {
1456
+ return Response.json({ reason: "not the watch secret" }, { status: 403 });
1457
+ }
1458
+ return await issue(config);
1459
+ };
1460
+ }
1461
+ function publicWatchTicketEndpoint(config) {
1462
+ return () => issue(config);
1463
+ }
1450
1464
  async function offer(config, url) {
1451
1465
  const amountMsat = await config.amountMsat();
1452
1466
  const quote = await config.gateway.createQuote({
@@ -1531,6 +1545,20 @@ function randomNonce() {
1531
1545
  function refuse(reason) {
1532
1546
  return Response.json({ status: "ERROR", reason });
1533
1547
  }
1548
+ async function issue(config) {
1549
+ const issued = await config.gateway.createSocketTicket({
1550
+ trigger: config.watchSecret,
1551
+ replay: config.replay
1552
+ });
1553
+ return Response.json({
1554
+ ticket: issued.ticket,
1555
+ expires_at: new Date(issued.expiresAt * 1e3).toISOString()
1556
+ });
1557
+ }
1558
+ async function offeredSecret(request) {
1559
+ const body = await request.json().catch(() => null);
1560
+ return typeof body?.secret === "string" ? body.secret : "";
1561
+ }
1534
1562
  // Annotate the CommonJS export names for ESM import in node:
1535
1563
  0 && (module.exports = {
1536
1564
  askWallet,
@@ -1546,5 +1574,7 @@ function refuse(reason) {
1546
1574
  nwcSettlement,
1547
1575
  nwcVerifyEndpoint,
1548
1576
  nwcVerifyUrl,
1549
- relayedVerifyUrl
1577
+ publicWatchTicketEndpoint,
1578
+ relayedVerifyUrl,
1579
+ watchTicketEndpoint
1550
1580
  });
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-DZzlN-bi.cjs';
2
- export { m as BlindLightningRailConfig, N as NwcConnection, n as NwcInvoice, o as NwcRailConfig, p as NwcVerifyConfig, q as Resolved, r as askWallet, s as blindLightningRail, u as invoiceFrom, v as nwcConnection, w as nwcHoldInvoice, x as nwcInvoice, y as nwcPay, z as nwcRail, A as nwcSettlement, D as nwcVerifyEndpoint, E as nwcVerifyUrl } from './rail-DZzlN-bi.cjs';
1
+ import { T as ThunderBridge } from './rail-CqUfuYXJ.cjs';
2
+ export { o as BlindLightningRailConfig, N as NwcConnection, p as NwcInvoice, q as NwcRailConfig, r as NwcVerifyConfig, s as Resolved, u as askWallet, v as blindLightningRail, w as invoiceFrom, x as nwcConnection, y as nwcHoldInvoice, z as nwcInvoice, A as nwcPay, D as nwcRail, E as nwcSettlement, G as nwcVerifyEndpoint, H as nwcVerifyUrl } from './rail-CqUfuYXJ.cjs';
3
3
 
4
4
  /** The wallet's own LUD-21 URL and the hash its preimage has to match */
5
5
  interface Relayed {
@@ -92,6 +92,23 @@ interface Minted {
92
92
  verifyUrl: string;
93
93
  expiresAt: number;
94
94
  }
95
+ /**
96
+ * A trigger's live stream is opened with a ticket rather than with the watch
97
+ * secret, so something has to hold the secret and trade it for tickets. That is
98
+ * what these two endpoints are, and they are the only place the gateway's token
99
+ * has to be
100
+ */
101
+ interface WatchTicketConfig {
102
+ /** The gateway that mints the ticket, holding the token this keeps off the wire */
103
+ gateway: ThunderBridge;
104
+ /** The trigger to open, the same secret `lnurlPayEndpoint` groups its payments under */
105
+ watchSecret: string;
106
+ /**
107
+ * How many of this trigger's settlements the socket replays on connect, so a
108
+ * page opened late still shows what it missed, up to the gateway's ceiling
109
+ */
110
+ replay?: number;
111
+ }
95
112
  /**
96
113
  * An LNURL-pay endpoint standing in front of a priority list of addresses, as a
97
114
  * Fetch handler so it runs on Deno Deploy, Workers, Hono, Next and Node alike.
@@ -105,5 +122,24 @@ interface Minted {
105
122
  * Nothing is stored between the two, so this holds no state of its own.
106
123
  */
107
124
  declare function lnurlPayEndpoint(config: TriggerConfig): (request: Request) => Promise<Response>;
125
+ /**
126
+ * Trades the watch secret for a socket ticket, for a board that is not public.
127
+ * The caller has to know the secret already, so all this adds is that the secret
128
+ * stops travelling in socket URLs, where the gateway, every proxy in front of it
129
+ * and the browser's own history all keep a copy. Anyone without it gets a 403.
130
+ *
131
+ * POST to it before every connect, because a ticket lives one minute.
132
+ */
133
+ declare function watchTicketEndpoint(config: WatchTicketConfig): (request: Request) => Promise<Response>;
134
+ /**
135
+ * Mints a socket ticket for anybody who asks, for a board meant to be read by
136
+ * strangers. It reads no body and refuses nobody, which makes the trigger's
137
+ * whole stream public: every viewer gets each settlement's preimage, verify url
138
+ * and payment hash.
139
+ *
140
+ * Only for a trigger where that is the point. Gate anything on those preimages
141
+ * and a viewer of the board is holding the unlock.
142
+ */
143
+ declare function publicWatchTicketEndpoint(config: WatchTicketConfig): (request: Request) => Promise<Response>;
108
144
 
109
- export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, lightningVerifyEndpoint, lnurlPayEndpoint, relayedVerifyUrl };
145
+ export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, type WatchTicketConfig, lightningVerifyEndpoint, lnurlPayEndpoint, publicWatchTicketEndpoint, relayedVerifyUrl, watchTicketEndpoint };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-DZzlN-bi.js';
2
- export { m as BlindLightningRailConfig, N as NwcConnection, n as NwcInvoice, o as NwcRailConfig, p as NwcVerifyConfig, q as Resolved, r as askWallet, s as blindLightningRail, u as invoiceFrom, v as nwcConnection, w as nwcHoldInvoice, x as nwcInvoice, y as nwcPay, z as nwcRail, A as nwcSettlement, D as nwcVerifyEndpoint, E as nwcVerifyUrl } from './rail-DZzlN-bi.js';
1
+ import { T as ThunderBridge } from './rail-CqUfuYXJ.js';
2
+ export { o as BlindLightningRailConfig, N as NwcConnection, p as NwcInvoice, q as NwcRailConfig, r as NwcVerifyConfig, s as Resolved, u as askWallet, v as blindLightningRail, w as invoiceFrom, x as nwcConnection, y as nwcHoldInvoice, z as nwcInvoice, A as nwcPay, D as nwcRail, E as nwcSettlement, G as nwcVerifyEndpoint, H as nwcVerifyUrl } from './rail-CqUfuYXJ.js';
3
3
 
4
4
  /** The wallet's own LUD-21 URL and the hash its preimage has to match */
5
5
  interface Relayed {
@@ -92,6 +92,23 @@ interface Minted {
92
92
  verifyUrl: string;
93
93
  expiresAt: number;
94
94
  }
95
+ /**
96
+ * A trigger's live stream is opened with a ticket rather than with the watch
97
+ * secret, so something has to hold the secret and trade it for tickets. That is
98
+ * what these two endpoints are, and they are the only place the gateway's token
99
+ * has to be
100
+ */
101
+ interface WatchTicketConfig {
102
+ /** The gateway that mints the ticket, holding the token this keeps off the wire */
103
+ gateway: ThunderBridge;
104
+ /** The trigger to open, the same secret `lnurlPayEndpoint` groups its payments under */
105
+ watchSecret: string;
106
+ /**
107
+ * How many of this trigger's settlements the socket replays on connect, so a
108
+ * page opened late still shows what it missed, up to the gateway's ceiling
109
+ */
110
+ replay?: number;
111
+ }
95
112
  /**
96
113
  * An LNURL-pay endpoint standing in front of a priority list of addresses, as a
97
114
  * Fetch handler so it runs on Deno Deploy, Workers, Hono, Next and Node alike.
@@ -105,5 +122,24 @@ interface Minted {
105
122
  * Nothing is stored between the two, so this holds no state of its own.
106
123
  */
107
124
  declare function lnurlPayEndpoint(config: TriggerConfig): (request: Request) => Promise<Response>;
125
+ /**
126
+ * Trades the watch secret for a socket ticket, for a board that is not public.
127
+ * The caller has to know the secret already, so all this adds is that the secret
128
+ * stops travelling in socket URLs, where the gateway, every proxy in front of it
129
+ * and the browser's own history all keep a copy. Anyone without it gets a 403.
130
+ *
131
+ * POST to it before every connect, because a ticket lives one minute.
132
+ */
133
+ declare function watchTicketEndpoint(config: WatchTicketConfig): (request: Request) => Promise<Response>;
134
+ /**
135
+ * Mints a socket ticket for anybody who asks, for a board meant to be read by
136
+ * strangers. It reads no body and refuses nobody, which makes the trigger's
137
+ * whole stream public: every viewer gets each settlement's preimage, verify url
138
+ * and payment hash.
139
+ *
140
+ * Only for a trigger where that is the point. Gate anything on those preimages
141
+ * and a viewer of the board is holding the unlock.
142
+ */
143
+ declare function publicWatchTicketEndpoint(config: WatchTicketConfig): (request: Request) => Promise<Response>;
108
144
 
109
- export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, lightningVerifyEndpoint, lnurlPayEndpoint, relayedVerifyUrl };
145
+ export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, type WatchTicketConfig, lightningVerifyEndpoint, lnurlPayEndpoint, publicWatchTicketEndpoint, relayedVerifyUrl, watchTicketEndpoint };
package/dist/server.js CHANGED
@@ -1398,6 +1398,18 @@ function lnurlPayEndpoint(config) {
1398
1398
  }
1399
1399
  };
1400
1400
  }
1401
+ function watchTicketEndpoint(config) {
1402
+ return async (request) => {
1403
+ const offered = await offeredSecret(request);
1404
+ if (!equalInConstantTime(sha256Hex(offered), sha256Hex(config.watchSecret))) {
1405
+ return Response.json({ reason: "not the watch secret" }, { status: 403 });
1406
+ }
1407
+ return await issue(config);
1408
+ };
1409
+ }
1410
+ function publicWatchTicketEndpoint(config) {
1411
+ return () => issue(config);
1412
+ }
1401
1413
  async function offer(config, url) {
1402
1414
  const amountMsat = await config.amountMsat();
1403
1415
  const quote = await config.gateway.createQuote({
@@ -1482,6 +1494,20 @@ function randomNonce() {
1482
1494
  function refuse(reason) {
1483
1495
  return Response.json({ status: "ERROR", reason });
1484
1496
  }
1497
+ async function issue(config) {
1498
+ const issued = await config.gateway.createSocketTicket({
1499
+ trigger: config.watchSecret,
1500
+ replay: config.replay
1501
+ });
1502
+ return Response.json({
1503
+ ticket: issued.ticket,
1504
+ expires_at: new Date(issued.expiresAt * 1e3).toISOString()
1505
+ });
1506
+ }
1507
+ async function offeredSecret(request) {
1508
+ const body = await request.json().catch(() => null);
1509
+ return typeof body?.secret === "string" ? body.secret : "";
1510
+ }
1485
1511
  export {
1486
1512
  askWallet,
1487
1513
  blindLightningRail,
@@ -1496,5 +1522,7 @@ export {
1496
1522
  nwcSettlement,
1497
1523
  nwcVerifyEndpoint,
1498
1524
  nwcVerifyUrl,
1499
- relayedVerifyUrl
1525
+ publicWatchTicketEndpoint,
1526
+ relayedVerifyUrl,
1527
+ watchTicketEndpoint
1500
1528
  };
package/openapi.yaml CHANGED
@@ -2,7 +2,7 @@ openapi: 3.1.0
2
2
 
3
3
  info:
4
4
  title: thunder-bridge, the endpoint your own service serves
5
- version: 1.2.0
5
+ version: 1.4.1
6
6
  license:
7
7
  name: MIT
8
8
  identifier: MIT
@@ -26,8 +26,12 @@ info:
26
26
 
27
27
  `bankVerifyEndpoint` is the other handler, and it puts a second rail behind the same
28
28
  contract: a bank transfer, proved to the gateway by the LUD-21 shape a Lightning wallet
29
- would have answered with. Both paths below are therefore yours to mount and yours to
30
- serve, and neither of them is the gateway.
29
+ would have answered with.
30
+
31
+ `watchTicketEndpoint` and `publicWatchTicketEndpoint` are the third, and they exist so a
32
+ browser can read a trigger's live stream without holding the credentials that open it.
33
+ Every path below is therefore yours to mount and yours to serve, and none of them is the
34
+ gateway.
31
35
 
32
36
  The gateway's own API is a separate document, `openapi.yaml` in the repository root, and
33
37
  the webhook your server receives is described there under `webhooks`.
@@ -43,6 +47,8 @@ tags:
43
47
  description: The two halves of LUD-06, on the one path a lightning address resolves to.
44
48
  - name: bank-transfer
45
49
  description: The settlement proof for money that arrived over a bank rail instead of Lightning.
50
+ - name: live-board
51
+ description: The exchange that lets a page watch a trigger without being given its secret.
46
52
 
47
53
  paths:
48
54
  /.well-known/lnurlp/{name}:
@@ -137,6 +143,57 @@ paths:
137
143
  - $ref: "#/components/schemas/invoice"
138
144
  - $ref: "#/components/schemas/refusal"
139
145
 
146
+ /watch/ticket:
147
+ post:
148
+ tags: [live-board]
149
+ operationId: mintWatchTicket
150
+ summary: Trade the watch secret for a socket ticket a browser may hold
151
+ description: |
152
+ Mounted wherever you like by `watchTicketEndpoint`, or by
153
+ `publicWatchTicketEndpoint` when the board is meant to be read by strangers. Which
154
+ one you mounted is the whole difference: the first compares the offered secret in
155
+ constant time and answers 403 without it, the second reads no body and refuses
156
+ nobody.
157
+
158
+ It exists because the socket is opened at the gateway's `/ws/tickets/{ticket}`, and
159
+ minting a ticket needs the bearer token and the watch secret. Neither may reach a
160
+ browser, so this endpoint holds both and hands back only the ticket, which opens one
161
+ trigger and nothing else.
162
+
163
+ The answer is the gateway's own, passed through unchanged. A ticket lives one minute,
164
+ so a page POSTs here immediately before every connect rather than caching one.
165
+
166
+ A public board is public in full. Every viewer of that socket gets each settlement's
167
+ preimage, verify url and payment hash, which is fine for a tip jar and wrong the
168
+ moment anything is gated behind those preimages.
169
+ requestBody:
170
+ required: false
171
+ content:
172
+ application/json:
173
+ schema:
174
+ type: object
175
+ properties:
176
+ secret:
177
+ type: string
178
+ description: |
179
+ The same watch secret the trigger groups its payments under. Required by
180
+ `watchTicketEndpoint` and ignored by `publicWatchTicketEndpoint`.
181
+ responses:
182
+ "200":
183
+ description: A ticket, good for one socket on one trigger for one minute.
184
+ content:
185
+ application/json:
186
+ schema:
187
+ $ref: "#/components/schemas/socket_ticket"
188
+ "403":
189
+ description: |
190
+ The body did not carry the watch secret. Never answered by
191
+ `publicWatchTicketEndpoint`, which does not read one.
192
+ content:
193
+ application/json:
194
+ schema:
195
+ $ref: "#/components/schemas/not_the_secret"
196
+
140
197
  /verify/bank:
141
198
  get:
142
199
  tags: [bank-transfer]
@@ -274,6 +331,30 @@ components:
274
331
  const: false
275
332
  required: [settled]
276
333
 
334
+ socket_ticket:
335
+ type: object
336
+ description: The gateway's ticket, passed through so a page reading `.ticket` needs no change.
337
+ properties:
338
+ ticket:
339
+ type: string
340
+ description: |
341
+ Goes in the gateway's `/ws/tickets/{ticket}` path. Signed, not stored, and bound
342
+ to the one trigger it was minted for.
343
+ expires_at:
344
+ type: string
345
+ format: date-time
346
+ description: A minute from minting. A ticket presented after it is refused at the handshake.
347
+ required: [ticket, expires_at]
348
+
349
+ not_the_secret:
350
+ type: object
351
+ description: A refusal that says only that the secret was wrong, never what was expected.
352
+ properties:
353
+ reason:
354
+ type: string
355
+ const: not the watch secret
356
+ required: [reason]
357
+
277
358
  ln_address:
278
359
  type: string
279
360
  format: email
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunder-bridge",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Trustless JavaScript client for the Thunder Bridge Lightning payment gateway. Proves the invoice came from your own wallet before the payer sees it.",
5
5
  "author": "i-am-fatik",
6
6
  "homepage": "https://agora.gripe/en/tools/thunder-bridge",