s402 0.5.0 → 0.7.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/CHANGELOG.md +74 -0
- package/README.md +11 -1
- package/dist/compat/l402.d.mts +94 -0
- package/dist/compat/l402.mjs +225 -0
- package/dist/compat/mpp.d.mts +160 -0
- package/dist/compat/mpp.mjs +363 -0
- package/dist/{compat.d.mts → compat/x402.d.mts} +3 -3
- package/dist/{compat.mjs → compat/x402.mjs} +5 -5
- package/dist/errors.d.mts +3 -1
- package/dist/errors.mjs +12 -2
- package/dist/http.d.mts +24 -7
- package/dist/http.mjs +31 -9
- package/dist/index.d.mts +302 -7
- package/dist/index.mjs +596 -87
- package/dist/test-utils.d.mts +1 -1
- package/dist/types.d.mts +2 -1
- package/dist/types.mjs +2 -1
- package/package.json +20 -6
- /package/dist/{scheme-m-uk4zyH.d.mts → scheme-M-z-UV0c.d.mts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,80 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.7.0] - 2026-04-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`s402/compat-l402` — L402 read-path interop (DAN-344).** New entry point for consuming Lightning Labs' L402 (formerly LSAT) challenges as native s402 types. L402 is the oldest 402 dialect in production — shipping this turns the "universal read" positioning pillar from aspirational into airtight.
|
|
15
|
+
- `parseWwwAuthenticateL402(header)` — RFC 9110 auth-params parser accepting both `L402` and legacy `LSAT` auth-schemes (canonicalized to `L402` in output). Handles quoted-string + unquoted-token forms. Enforces required `macaroon` and `invoice` params.
|
|
16
|
+
- `decodeBolt11Summary(invoice)` — partial BOLT-11 decoder over the human-readable part only. Extracts network (`lightning:mainnet|testnet|regtest|signet`) and amount (converting m/u/n/p multipliers to millisatoshi with BigInt arithmetic). Rejects pico-BTC amounts not divisible by 10.
|
|
17
|
+
- `fromL402Challenge(challenge)` — translates an L402 challenge into `s402PaymentRequirements` with `scheme: 'exact'`, `asset: 'lightning:msat'`, sentinel `payTo: 'lightning:invoice'` (real destination lives in the invoice). Surfaces macaroon + invoice in `extensions.l402` for retry construction. Rejects amountless invoices as spec violations. Stamps a conservative `expiresAt = now + 60s` so that **S1 (stale payment rejection) stays load-bearing** for L402-derived requirements — the real BOLT-11 expiry tag is not decoded in v0.7 (scope deferral); the 60s floor guards against stale-invoice replay, with the tradeoff that long-expiry invoices trigger a re-fetch after 60s.
|
|
18
|
+
- **Signet prefix support**: recognizes both the canonical current-BOLT-11 prefix (`lntbs`, core-lightning + recent LND) and the legacy prefix (`lnsb`, older LND emissions). Both canonicalize to `lightning:signet` in the parsed output.
|
|
19
|
+
- **~20 unit tests** at `test/compat-l402.test.ts` covering all four multiplier classes, all four network prefixes, LSAT/L402 alias handling, amountless invoices, malformed HRPs, and end-to-end header-to-requirements flows.
|
|
20
|
+
- **Positioning document** at `docs/positioning.md` — canonical three-pillar USP: expressiveness (6 schemes), universal read (every 402 dialect), on-chain enforcement (Move invariants). Single source of truth for landing page, pitch, and grant copy.
|
|
21
|
+
- **Universal 402 Absorption** project tracker on Linear ([project link](https://linear.app/dannydevs/project/universal-402-absorption-f6e181082db4)) with child issues DAN-344 (L402), DAN-345 (MPP Session), DAN-346 (MPP write path), DAN-347 (Google AP2), DAN-348 (IETF reference impl), DAN-349 (ERC-7824 watch).
|
|
22
|
+
|
|
23
|
+
### Scope (intentionally deferred)
|
|
24
|
+
|
|
25
|
+
- **L402 write path** — emitting L402 challenges requires a Lightning node to mint BOLT-11 invoices; out of scope for a wire-format library. Teams that need emission should keep Aperture in the path.
|
|
26
|
+
- **Macaroon caveat decoding** — passed through opaque in v0.7; caveat introspection delegated to `node-macaroon` or equivalent.
|
|
27
|
+
- **Full BOLT-11 tagged-field decoding** — node pubkey, routing hints, payment hash, description. Lightning wallets already decode these; we do not duplicate their work.
|
|
28
|
+
- **BOLT-12 offers** — newer offer-based protocol, spec still evolving.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `docs/integrations.md` — added L402 compat-layer row (✅ v0.7).
|
|
33
|
+
- `docs/guide/upgrade-l402.md` — new migration guide covering consumption, coexistence via `Accept-Payment`, BOLT-11 multiplier table, and honest comparison with L402.
|
|
34
|
+
|
|
35
|
+
### Breaking
|
|
36
|
+
|
|
37
|
+
- **Minimum Node.js bumped to 20** (from 18). Node 18 reached end-of-life April 2025; `envelope.ts`'s `computeTxBinding` relies on `globalThis.crypto.subtle` which is only available unflagged in Node 19+. `engines.node` updated to `>=20`, CI matrix dropped Node 18, README/docs updated. Node 20 and Node 22 remain fully supported.
|
|
38
|
+
|
|
39
|
+
### Compatibility
|
|
40
|
+
|
|
41
|
+
- **Non-compat consumers are additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors.
|
|
42
|
+
- **Compat sub-path exports reorganized**: all three compat layers now live under `s402/compat/*` for symmetry and clearer intent.
|
|
43
|
+
- `s402/compat` → **`s402/compat/x402`** (breaking rename — x402 is now explicit, not the unlabeled default)
|
|
44
|
+
- `s402/compat-mpp` → **`s402/compat/mpp`**
|
|
45
|
+
- `s402/compat-l402` → **`s402/compat/l402`** (new in this release; shipped under the new path from day one)
|
|
46
|
+
- Source tree moved from flat `src/compat.ts`, `src/compat-mpp.ts`, `src/compat-l402.ts` to `src/compat/x402.ts`, `src/compat/mpp.ts`, `src/compat/l402.ts`. Pre-1.0 minor bump licenses the rename; no backward-compat aliases shipped — consumers update imports once.
|
|
47
|
+
- **Migration**: find-replace `'s402/compat'` → `'s402/compat/x402'`, `'s402/compat-mpp'` → `'s402/compat/mpp'`, `'s402/compat-l402'` → `'s402/compat/l402'`. Exported symbol names are unchanged.
|
|
48
|
+
- Root `s402` entry still pulls no compat bundle — compat layers remain opt-in.
|
|
49
|
+
|
|
50
|
+
## [0.6.0] - 2026-04-19
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`s402/compat-mpp` — MPP read-path interop (DAN-339).** New entry point for consuming Stripe/Tempo Machine Payment Protocol 402 responses as native s402 types. All parsing is grounded against the actual MPP spec drafts in `tempoxyz/mpp-specs` (draft-httpauth-payment-00, draft-payment-intent-charge-00), not hearsay.
|
|
55
|
+
- `parseWwwAuthenticatePayment(header)` — RFC 9110 auth-params parser for `WWW-Authenticate: Payment`. Handles quoted-string escapes, unquoted tokens, enforces required `id`/`realm`/`method`/`intent`/`request`, preserves optional `digest`/`expires`/`description`/`opaque`.
|
|
56
|
+
- `parseMppAcceptPayment(header)` — method/intent pair grammar with wildcards on either side (`tempo/charge`, `tempo/*`, `*/session`, `*/*`) and q-values per core spec §6.1. Stable sort by descending q, preserves client order on ties.
|
|
57
|
+
- `matchMppRange(range, method, intent)` — specificity scoring (exact=2, one-wild=1, all-wild=0, no-match=−1) for the "prefer most specific matching range" rule.
|
|
58
|
+
- `decodeMppChargeRequest(challenge)` — decodes the base64url JCS `request` blob for the charge intent. Validates `amount` as non-negative integer, requires `currency`, preserves `methodDetails` untouched.
|
|
59
|
+
- `decodeMppCredential(authorizationHeader)` — base64url-nopad `Authorization: Payment <...>` decoder with trust-boundary shape validation on `challenge` and `payload`.
|
|
60
|
+
- `fromMppChargeChallenge(challenge, now?)` — translates blockchain-method Charge challenges (`tempo`/`evm`/`solana`/`lightning`/`stellar`) into `s402PaymentRequirements` with `scheme: 'exact'`. Resolves network via `eip155:{chainId}` / `tempo:{chainId}` conventions, carries challenge provenance into `extensions.mpp` for downstream routing, rejects processor-based methods (Stripe/card have no payTo in the Charge request), rejects expired challenges.
|
|
61
|
+
- **40 spec-grounded unit tests** at `test/compat-mpp.test.ts` drawn from the spec's own §5.1.4 / §6.1 / §Request Schema fixtures.
|
|
62
|
+
- **ADR-005 — Interop When Possible, Superset When Wise.** The governing strategic principle behind the compat layer: absorb x402/MPP as payment-in formats where their design is legitimate; superset them on primitives their business models forbid. See `docs/adr/005-interop-superset-principle.md`.
|
|
63
|
+
|
|
64
|
+
### Scope (intentionally deferred to v0.7+)
|
|
65
|
+
|
|
66
|
+
- Session intent (cumulative voucher ↔ Prepaid translation shim)
|
|
67
|
+
- Method-specific credential-tier dispatch (EVM `permit2`/`authorization`/`transaction`/`hash`; Tempo `transaction`/`hash`/`proof`)
|
|
68
|
+
- HMAC-SHA256 challenge-binding verification (server-side, needs secret)
|
|
69
|
+
- Write path — emitting MPP-shaped `WWW-Authenticate: Payment` challenges from an s402 server
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- **956 tests across 21 files** (was 916). The 40 new compat-mpp tests join 30 unit + 6 live-server integration tests for `Accept-Payment` that shipped earlier in the 0.5 dev cycle.
|
|
74
|
+
- Migration guide (`docs/guide/upgrade-mpp.md`) updated to reference real exported APIs rather than placeholder code.
|
|
75
|
+
- `docs/integrations.md` compat-layer table updated: MPP Charge (read) is 🟡 v0.3, MPP `Accept-Payment` is ✅ Production, MPP Charge (write) and Session remain 📋 roadmap.
|
|
76
|
+
|
|
77
|
+
### Compatibility
|
|
78
|
+
|
|
79
|
+
- **Purely additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors. Existing 0.5.x consumers require no code changes.
|
|
80
|
+
- **New sub-path export**: `s402/compat-mpp` sits alongside the existing `s402/compat` (x402 interop). Both are opt-in — importing from the root `s402` entry does not pull the compat bundles.
|
|
81
|
+
|
|
8
82
|
## [0.5.0] - 2026-04-12
|
|
9
83
|
|
|
10
84
|
### Added
|
package/README.md
CHANGED
|
@@ -14,7 +14,17 @@ bun add s402
|
|
|
14
14
|
deno add npm:s402
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
> **ESM-only.** This package ships ES modules only (`"type": "module"`). Requires Node.js >=
|
|
17
|
+
> **ESM-only.** This package ships ES modules only (`"type": "module"`). Requires Node.js >= 20. CommonJS `require()` is not supported.
|
|
18
|
+
|
|
19
|
+
## Governing Principle
|
|
20
|
+
|
|
21
|
+
> **We interop when possible. We superset when wise.**
|
|
22
|
+
|
|
23
|
+
s402 does not fight x402 or Stripe MPP head-on. s402 **absorbs** them as payment-in formats where their design choices are legitimate (exact, upto), and **supersets** them on primitives their business models cannot ship (prepaid with on-chain ceiling, streaming with rate enforcement, escrow with arbiter, Seal-encrypted unlock).
|
|
24
|
+
|
|
25
|
+
This is the Postgres-eats-MySQL move: the superset always eats the subset because adopters never lose what they had — they only gain. The asymmetry is in s402's favor because competitors' constraints forbid reciprocating. Stripe cannot accept s402 schemes without bypassing card-processing margin. x402's 2-scheme governance envelope cannot absorb s402's 5 without re-ratification.
|
|
26
|
+
|
|
27
|
+
See [ADR-005](./docs/adr/005-interop-superset-principle.md) for the full reasoning.
|
|
18
28
|
|
|
19
29
|
## Why s402?
|
|
20
30
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { s402PaymentRequirements } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/compat/l402.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parsed `WWW-Authenticate: L402` (or legacy `LSAT`) challenge.
|
|
6
|
+
*
|
|
7
|
+
* Per Lightning Labs' spec, an L402 challenge carries exactly two required
|
|
8
|
+
* auth-params: the `macaroon` (opaque bearer token with caveats) and the
|
|
9
|
+
* `invoice` (BOLT-11 payment request). The client pays the invoice via Lightning,
|
|
10
|
+
* receives the preimage, and presents `Authorization: L402 <macaroon>:<preimage>`
|
|
11
|
+
* on the retry.
|
|
12
|
+
*/
|
|
13
|
+
interface L402Challenge {
|
|
14
|
+
/** Canonicalized auth-scheme — always `"L402"`, even if the wire said `LSAT`. */
|
|
15
|
+
scheme: 'L402';
|
|
16
|
+
/** Base64-encoded macaroon. Treated as opaque by this module. */
|
|
17
|
+
macaroon: string;
|
|
18
|
+
/** BOLT-11 invoice (bech32 `ln...`). */
|
|
19
|
+
invoice: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Decoded BOLT-11 human-readable part (HRP). Only fields the translator needs.
|
|
23
|
+
*
|
|
24
|
+
* `amountMsat` is `null` for amountless invoices — BOLT-11 allows these, but
|
|
25
|
+
* they are unusual in L402 contexts since Aperture embeds the price in the
|
|
26
|
+
* invoice. Callers translating to s402 typically reject amountless invoices.
|
|
27
|
+
*/
|
|
28
|
+
interface Bolt11Summary {
|
|
29
|
+
network: 'lightning:mainnet' | 'lightning:testnet' | 'lightning:regtest' | 'lightning:signet';
|
|
30
|
+
/** Amount in millisatoshi as a non-negative integer string, or `null` if the invoice specifies no amount. */
|
|
31
|
+
amountMsat: string | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse a `WWW-Authenticate: L402 ...` header into an {@link L402Challenge}.
|
|
35
|
+
*
|
|
36
|
+
* Accepts both `L402` and legacy `LSAT` auth-schemes (case-insensitive) —
|
|
37
|
+
* Aperture in the wild still emits `LSAT` on older deployments. The output
|
|
38
|
+
* scheme is always canonicalized to `"L402"`.
|
|
39
|
+
*
|
|
40
|
+
* Returns `null` if the header is absent/empty or does not start with an L402
|
|
41
|
+
* auth-scheme. Throws `INVALID_PAYLOAD` if the scheme is present but required
|
|
42
|
+
* params (`macaroon`, `invoice`) are missing or malformed.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const challenge = parseWwwAuthenticateL402(res.headers.get('WWW-Authenticate'));
|
|
47
|
+
* if (challenge) {
|
|
48
|
+
* const requirements = fromL402Challenge(challenge);
|
|
49
|
+
* // requirements.scheme === 'exact', requirements.network === 'lightning:mainnet', ...
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
declare function parseWwwAuthenticateL402(header: string | null | undefined): L402Challenge | null;
|
|
54
|
+
/**
|
|
55
|
+
* Decode the BOLT-11 human-readable part of a Lightning invoice into its
|
|
56
|
+
* network and amount components. This is a **partial** decoder — it reads only
|
|
57
|
+
* the HRP (up to the bech32 `1` separator) because full BOLT-11 decoding
|
|
58
|
+
* requires bech32 + tagged-field parsing (~500 LOC) and the translator only
|
|
59
|
+
* needs network + amount.
|
|
60
|
+
*
|
|
61
|
+
* BOLT-11 HRP grammar: `ln{prefix}{amount?}{multiplier?}` where
|
|
62
|
+
* - prefix ∈ {`bc`, `tb`, `bcrt`, `sb`} (mainnet/testnet/regtest/signet)
|
|
63
|
+
* - amount is a decimal integer (BTC units before multiplier)
|
|
64
|
+
* - multiplier ∈ {`m`, `u`, `n`, `p`} (milli/micro/nano/pico-BTC)
|
|
65
|
+
*
|
|
66
|
+
* Conversion to millisatoshi (msat = 10^-11 BTC):
|
|
67
|
+
* - no multiplier: `amount * 10^11` msat
|
|
68
|
+
* - `m`: `amount * 10^8` msat
|
|
69
|
+
* - `u`: `amount * 10^5` msat
|
|
70
|
+
* - `n`: `amount * 10^2` msat
|
|
71
|
+
* - `p`: `amount / 10` msat (amount must be multiple of 10)
|
|
72
|
+
*
|
|
73
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the HRP is malformed, the prefix is
|
|
74
|
+
* unknown, or a pico-BTC amount is not a multiple of 10.
|
|
75
|
+
*/
|
|
76
|
+
declare function decodeBolt11Summary(invoice: string): Bolt11Summary;
|
|
77
|
+
/**
|
|
78
|
+
* Translate an L402 challenge into s402 payment requirements using the `exact`
|
|
79
|
+
* scheme.
|
|
80
|
+
*
|
|
81
|
+
* The resulting requirements are consumable by a Lightning-aware s402 client.
|
|
82
|
+
* The `payTo` field is a sentinel (`"lightning:invoice"`) rather than a node
|
|
83
|
+
* pubkey because the true destination is inside the BOLT-11 invoice — which
|
|
84
|
+
* Lightning wallets decode themselves. The invoice and macaroon are surfaced
|
|
85
|
+
* under `extensions.l402` so the client can present them back on the retry
|
|
86
|
+
* (`Authorization: L402 <macaroon>:<preimage>`).
|
|
87
|
+
*
|
|
88
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the invoice HRP is malformed or the
|
|
89
|
+
* invoice is amountless (L402 challenges always specify a price in the
|
|
90
|
+
* invoice; an amountless invoice is a spec violation).
|
|
91
|
+
*/
|
|
92
|
+
declare function fromL402Challenge(challenge: L402Challenge): s402PaymentRequirements;
|
|
93
|
+
//#endregion
|
|
94
|
+
export { Bolt11Summary, L402Challenge, decodeBolt11Summary, fromL402Challenge, parseWwwAuthenticateL402 };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { S402_VERSION } from "../types.mjs";
|
|
2
|
+
import { s402Error } from "../errors.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/compat/l402.ts
|
|
5
|
+
const TOKEN_CHARS = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
6
|
+
const L402_SCHEME_PATTERN = /^\s*(L402|LSAT)(?:\s+(.*))?$/i;
|
|
7
|
+
/**
|
|
8
|
+
* Parse a `WWW-Authenticate: L402 ...` header into an {@link L402Challenge}.
|
|
9
|
+
*
|
|
10
|
+
* Accepts both `L402` and legacy `LSAT` auth-schemes (case-insensitive) —
|
|
11
|
+
* Aperture in the wild still emits `LSAT` on older deployments. The output
|
|
12
|
+
* scheme is always canonicalized to `"L402"`.
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` if the header is absent/empty or does not start with an L402
|
|
15
|
+
* auth-scheme. Throws `INVALID_PAYLOAD` if the scheme is present but required
|
|
16
|
+
* params (`macaroon`, `invoice`) are missing or malformed.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const challenge = parseWwwAuthenticateL402(res.headers.get('WWW-Authenticate'));
|
|
21
|
+
* if (challenge) {
|
|
22
|
+
* const requirements = fromL402Challenge(challenge);
|
|
23
|
+
* // requirements.scheme === 'exact', requirements.network === 'lightning:mainnet', ...
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
function parseWwwAuthenticateL402(header) {
|
|
28
|
+
if (!header) return null;
|
|
29
|
+
const match = L402_SCHEME_PATTERN.exec(header);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
const paramString = (match[2] ?? "").trim();
|
|
32
|
+
if (paramString.length === 0) throw new s402Error("INVALID_PAYLOAD", "L402 challenge missing auth-params");
|
|
33
|
+
const params = parseAuthParams(paramString);
|
|
34
|
+
const macaroon = params.macaroon;
|
|
35
|
+
const invoice = params.invoice;
|
|
36
|
+
if (typeof macaroon !== "string" || macaroon.length === 0) throw new s402Error("INVALID_PAYLOAD", "L402 challenge missing \"macaroon\" auth-param");
|
|
37
|
+
if (typeof invoice !== "string" || invoice.length === 0) throw new s402Error("INVALID_PAYLOAD", "L402 challenge missing \"invoice\" auth-param");
|
|
38
|
+
return {
|
|
39
|
+
scheme: "L402",
|
|
40
|
+
macaroon,
|
|
41
|
+
invoice
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse RFC 9110 §11.2 `auth-params` (`token "=" ( token / quoted-string )`
|
|
46
|
+
* comma-separated). L402 uses the same grammar as MPP; this function mirrors
|
|
47
|
+
* the MPP parser in `compat-mpp` rather than sharing a helper because the two
|
|
48
|
+
* dialects may diverge on edge cases (e.g., L402 invoices have not historically
|
|
49
|
+
* been quoted in Aperture output, whereas MPP params are consistently quoted).
|
|
50
|
+
*/
|
|
51
|
+
function parseAuthParams(input) {
|
|
52
|
+
const out = {};
|
|
53
|
+
let i = 0;
|
|
54
|
+
const n = input.length;
|
|
55
|
+
while (i < n) {
|
|
56
|
+
while (i < n && (input[i] === " " || input[i] === " " || input[i] === ",")) i++;
|
|
57
|
+
if (i >= n) break;
|
|
58
|
+
const keyStart = i;
|
|
59
|
+
while (i < n && input[i] !== "=" && input[i] !== " " && input[i] !== " ") i++;
|
|
60
|
+
const key = input.slice(keyStart, i).toLowerCase();
|
|
61
|
+
if (key.length === 0 || !TOKEN_CHARS.test(key)) throw new s402Error("INVALID_PAYLOAD", `Malformed auth-param name at position ${keyStart}`);
|
|
62
|
+
while (i < n && (input[i] === " " || input[i] === " ")) i++;
|
|
63
|
+
if (input[i] !== "=") throw new s402Error("INVALID_PAYLOAD", `Missing "=" after auth-param "${key}"`);
|
|
64
|
+
i++;
|
|
65
|
+
while (i < n && (input[i] === " " || input[i] === " ")) i++;
|
|
66
|
+
let value;
|
|
67
|
+
if (input[i] === "\"") {
|
|
68
|
+
i++;
|
|
69
|
+
const valueStart = i;
|
|
70
|
+
let raw = "";
|
|
71
|
+
while (i < n && input[i] !== "\"") if (input[i] === "\\" && i + 1 < n) {
|
|
72
|
+
raw += input[i + 1];
|
|
73
|
+
i += 2;
|
|
74
|
+
} else {
|
|
75
|
+
raw += input[i];
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
if (input[i] !== "\"") throw new s402Error("INVALID_PAYLOAD", `Unterminated quoted-string starting at position ${valueStart}`);
|
|
79
|
+
i++;
|
|
80
|
+
value = raw;
|
|
81
|
+
} else {
|
|
82
|
+
const valueStart = i;
|
|
83
|
+
while (i < n && input[i] !== "," && input[i] !== " " && input[i] !== " ") i++;
|
|
84
|
+
value = input.slice(valueStart, i);
|
|
85
|
+
if (value.length === 0) throw new s402Error("INVALID_PAYLOAD", `Empty auth-param value for "${key}" at position ${valueStart}`);
|
|
86
|
+
}
|
|
87
|
+
out[key] = value;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
const HRP_PATTERN = /^ln(bcrt|tbs|bc|tb|sb)(\d+)?([munp])?1[a-z0-9]+$/;
|
|
92
|
+
const NETWORK_BY_PREFIX = {
|
|
93
|
+
bc: "lightning:mainnet",
|
|
94
|
+
tb: "lightning:testnet",
|
|
95
|
+
bcrt: "lightning:regtest",
|
|
96
|
+
tbs: "lightning:signet",
|
|
97
|
+
sb: "lightning:signet"
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Decode the BOLT-11 human-readable part of a Lightning invoice into its
|
|
101
|
+
* network and amount components. This is a **partial** decoder — it reads only
|
|
102
|
+
* the HRP (up to the bech32 `1` separator) because full BOLT-11 decoding
|
|
103
|
+
* requires bech32 + tagged-field parsing (~500 LOC) and the translator only
|
|
104
|
+
* needs network + amount.
|
|
105
|
+
*
|
|
106
|
+
* BOLT-11 HRP grammar: `ln{prefix}{amount?}{multiplier?}` where
|
|
107
|
+
* - prefix ∈ {`bc`, `tb`, `bcrt`, `sb`} (mainnet/testnet/regtest/signet)
|
|
108
|
+
* - amount is a decimal integer (BTC units before multiplier)
|
|
109
|
+
* - multiplier ∈ {`m`, `u`, `n`, `p`} (milli/micro/nano/pico-BTC)
|
|
110
|
+
*
|
|
111
|
+
* Conversion to millisatoshi (msat = 10^-11 BTC):
|
|
112
|
+
* - no multiplier: `amount * 10^11` msat
|
|
113
|
+
* - `m`: `amount * 10^8` msat
|
|
114
|
+
* - `u`: `amount * 10^5` msat
|
|
115
|
+
* - `n`: `amount * 10^2` msat
|
|
116
|
+
* - `p`: `amount / 10` msat (amount must be multiple of 10)
|
|
117
|
+
*
|
|
118
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the HRP is malformed, the prefix is
|
|
119
|
+
* unknown, or a pico-BTC amount is not a multiple of 10.
|
|
120
|
+
*/
|
|
121
|
+
function decodeBolt11Summary(invoice) {
|
|
122
|
+
if (typeof invoice !== "string" || invoice.length === 0) throw new s402Error("INVALID_PAYLOAD", "BOLT-11 invoice must be a non-empty string");
|
|
123
|
+
const lower = invoice.toLowerCase();
|
|
124
|
+
const match = HRP_PATTERN.exec(lower);
|
|
125
|
+
if (!match) throw new s402Error("INVALID_PAYLOAD", `Invoice does not match BOLT-11 HRP grammar (expected "ln(bc|tb|bcrt|sb){amount}{m|u|n|p}1..."): "${invoice}"`);
|
|
126
|
+
const prefix = match[1];
|
|
127
|
+
const amountPart = match[2];
|
|
128
|
+
const multiplier = match[3];
|
|
129
|
+
const network = NETWORK_BY_PREFIX[prefix];
|
|
130
|
+
if (!network) throw new s402Error("INVALID_PAYLOAD", `Unknown BOLT-11 network prefix: "${prefix}"`);
|
|
131
|
+
if (amountPart === void 0) {
|
|
132
|
+
if (multiplier !== void 0) throw new s402Error("INVALID_PAYLOAD", "BOLT-11 multiplier without amount");
|
|
133
|
+
return {
|
|
134
|
+
network,
|
|
135
|
+
amountMsat: null
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const amount = BigInt(amountPart);
|
|
139
|
+
let amountMsat;
|
|
140
|
+
switch (multiplier) {
|
|
141
|
+
case void 0:
|
|
142
|
+
amountMsat = amount * 100000000000n;
|
|
143
|
+
break;
|
|
144
|
+
case "m":
|
|
145
|
+
amountMsat = amount * 100000000n;
|
|
146
|
+
break;
|
|
147
|
+
case "u":
|
|
148
|
+
amountMsat = amount * 100000n;
|
|
149
|
+
break;
|
|
150
|
+
case "n":
|
|
151
|
+
amountMsat = amount * 100n;
|
|
152
|
+
break;
|
|
153
|
+
case "p":
|
|
154
|
+
if (amount % 10n !== 0n) throw new s402Error("INVALID_PAYLOAD", `BOLT-11 pico-BTC amount must be a multiple of 10 (got ${amount}) — 1 msat is the minimum divisible unit`);
|
|
155
|
+
amountMsat = amount / 10n;
|
|
156
|
+
break;
|
|
157
|
+
default: throw new s402Error("INVALID_PAYLOAD", `Unknown BOLT-11 multiplier: "${multiplier}"`);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
network,
|
|
161
|
+
amountMsat: amountMsat.toString()
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Sentinel payTo for Lightning — the actual payment destination is encoded in
|
|
166
|
+
* the invoice itself (BOLT-11 tagged fields carry node pubkey + payment hash).
|
|
167
|
+
* An s402 client paying an L402 challenge routes through a Lightning wallet
|
|
168
|
+
* that knows how to pay an invoice; the `payTo` field exists only to satisfy
|
|
169
|
+
* the s402 schema.
|
|
170
|
+
*/
|
|
171
|
+
const LIGHTNING_INVOICE_SENTINEL = "lightning:invoice";
|
|
172
|
+
/**
|
|
173
|
+
* Conservative default expiry window applied to L402-derived requirements.
|
|
174
|
+
*
|
|
175
|
+
* BOLT-11 invoices carry their own expiry as a tagged field (type `x`) past
|
|
176
|
+
* the `1` separator, defaulting to 3600 seconds per spec. This partial decoder
|
|
177
|
+
* reads only the HRP, so the real invoice expiry is not surfaced. To keep
|
|
178
|
+
* S1 (stale payment rejection) load-bearing for L402-derived requirements,
|
|
179
|
+
* we stamp a conservative 60s window: an s402 client that caches requirements
|
|
180
|
+
* longer than 60s must re-fetch the 402 response rather than reusing stale
|
|
181
|
+
* ones against a possibly-expired invoice.
|
|
182
|
+
*
|
|
183
|
+
* Tradeoff: a long-lived invoice (e.g., Aperture's default 1-hour expiry) is
|
|
184
|
+
* rejected by s402 after 60s even though the invoice is still payable. The
|
|
185
|
+
* re-fetch cost is one extra round-trip, not a payment failure.
|
|
186
|
+
*
|
|
187
|
+
* A future v0.8 full BOLT-11 decoder can read the `x` tag and use the real
|
|
188
|
+
* expiry. Until then, 60s is the safe floor.
|
|
189
|
+
*/
|
|
190
|
+
const L402_DEFAULT_EXPIRY_WINDOW_MS = 6e4;
|
|
191
|
+
/**
|
|
192
|
+
* Translate an L402 challenge into s402 payment requirements using the `exact`
|
|
193
|
+
* scheme.
|
|
194
|
+
*
|
|
195
|
+
* The resulting requirements are consumable by a Lightning-aware s402 client.
|
|
196
|
+
* The `payTo` field is a sentinel (`"lightning:invoice"`) rather than a node
|
|
197
|
+
* pubkey because the true destination is inside the BOLT-11 invoice — which
|
|
198
|
+
* Lightning wallets decode themselves. The invoice and macaroon are surfaced
|
|
199
|
+
* under `extensions.l402` so the client can present them back on the retry
|
|
200
|
+
* (`Authorization: L402 <macaroon>:<preimage>`).
|
|
201
|
+
*
|
|
202
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the invoice HRP is malformed or the
|
|
203
|
+
* invoice is amountless (L402 challenges always specify a price in the
|
|
204
|
+
* invoice; an amountless invoice is a spec violation).
|
|
205
|
+
*/
|
|
206
|
+
function fromL402Challenge(challenge) {
|
|
207
|
+
const summary = decodeBolt11Summary(challenge.invoice);
|
|
208
|
+
if (summary.amountMsat === null) throw new s402Error("INVALID_PAYLOAD", "L402 invoice is amountless — L402 challenges must specify an exact price via the BOLT-11 amount");
|
|
209
|
+
return {
|
|
210
|
+
s402Version: S402_VERSION,
|
|
211
|
+
accepts: ["exact"],
|
|
212
|
+
network: summary.network,
|
|
213
|
+
asset: "lightning:msat",
|
|
214
|
+
amount: summary.amountMsat,
|
|
215
|
+
payTo: LIGHTNING_INVOICE_SENTINEL,
|
|
216
|
+
expiresAt: Date.now() + L402_DEFAULT_EXPIRY_WINDOW_MS,
|
|
217
|
+
extensions: { l402: {
|
|
218
|
+
macaroon: challenge.macaroon,
|
|
219
|
+
invoice: challenge.invoice
|
|
220
|
+
} }
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//#endregion
|
|
225
|
+
export { decodeBolt11Summary, fromL402Challenge, parseWwwAuthenticateL402 };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { s402PaymentRequirements } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/compat/mpp.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parsed `WWW-Authenticate: Payment` challenge parameters.
|
|
6
|
+
*
|
|
7
|
+
* Required params per core spec §5.1.1: id, realm, method, intent, request.
|
|
8
|
+
* Optional params per §5.1.2: digest, expires, description, opaque.
|
|
9
|
+
* `request` is a base64url-nopad JCS-encoded JSON object — decoded separately
|
|
10
|
+
* by intent-specific parsers (see {@link decodeMppChargeRequest}).
|
|
11
|
+
*/
|
|
12
|
+
interface MppChallenge {
|
|
13
|
+
id: string;
|
|
14
|
+
realm: string;
|
|
15
|
+
method: string;
|
|
16
|
+
intent: string;
|
|
17
|
+
request: string;
|
|
18
|
+
digest?: string;
|
|
19
|
+
expires?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
opaque?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Shared Charge-intent request fields per `draft-payment-intent-charge-00` §Request Schema.
|
|
25
|
+
*
|
|
26
|
+
* `amount` + `currency` are required across every method. `recipient` is
|
|
27
|
+
* REQUIRED for blockchain methods and OPTIONAL for processor-based methods
|
|
28
|
+
* (Stripe routes internally). `methodDetails` holds method-specific extension
|
|
29
|
+
* data (chainId, permit2Address, invoice, networkId, paymentMethodTypes, ...).
|
|
30
|
+
*/
|
|
31
|
+
interface MppChargeRequest {
|
|
32
|
+
amount: string;
|
|
33
|
+
currency: string;
|
|
34
|
+
recipient?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
externalId?: string;
|
|
37
|
+
methodDetails?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Decoded `Authorization: Payment <base64url>` credential per core spec §5.2.
|
|
41
|
+
*
|
|
42
|
+
* `challenge` echoes the original challenge params (verified server-side).
|
|
43
|
+
* `payload` is method-specific (Permit2 signature, Lightning preimage, Stripe
|
|
44
|
+
* confirmation id, ...). `source` is RECOMMENDED DID format for payer identity.
|
|
45
|
+
*/
|
|
46
|
+
interface MppCredential {
|
|
47
|
+
challenge: {
|
|
48
|
+
id: string;
|
|
49
|
+
realm: string;
|
|
50
|
+
method: string;
|
|
51
|
+
intent: string;
|
|
52
|
+
request: string;
|
|
53
|
+
digest?: string;
|
|
54
|
+
expires?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
opaque?: string;
|
|
57
|
+
};
|
|
58
|
+
source?: string;
|
|
59
|
+
payload: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Entry in an MPP `Accept-Payment` header.
|
|
63
|
+
*
|
|
64
|
+
* MPP preference uses method/intent pairs with wildcards on either side
|
|
65
|
+
* (`tempo/x`, `x/session`, `x/x` where `x` is the literal `*`) and q-values
|
|
66
|
+
* per RFC 9110. This differs from s402's flat scheme tokens (`s402/exact`,
|
|
67
|
+
* `s402/prepaid`) which `parseAcceptPayment` treats as opaque strings.
|
|
68
|
+
*/
|
|
69
|
+
interface MppPaymentRange {
|
|
70
|
+
/** Lowercase method id or "*" wildcard. */
|
|
71
|
+
method: string;
|
|
72
|
+
/** Intent token or "*" wildcard. */
|
|
73
|
+
intent: string;
|
|
74
|
+
/** Quality factor in [0,1]; 0 means "do not use". */
|
|
75
|
+
q: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse a `WWW-Authenticate: Payment ...` header into an {@link MppChallenge}.
|
|
79
|
+
*
|
|
80
|
+
* Returns null if the header is absent, empty, or doesn't start with `Payment`.
|
|
81
|
+
* Throws `INVALID_PAYLOAD` if the Payment scheme is present but required params
|
|
82
|
+
* are missing. Accepts a single Payment challenge per header line — per core
|
|
83
|
+
* spec §7.1 (Intent Negotiation), servers emitting multiple challenges send
|
|
84
|
+
* one header per challenge.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const header = res.headers.get('WWW-Authenticate');
|
|
89
|
+
* const challenge = parseWwwAuthenticatePayment(header);
|
|
90
|
+
* if (challenge?.intent === 'charge') {
|
|
91
|
+
* const req = decodeMppChargeRequest(challenge);
|
|
92
|
+
* // ...
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
declare function parseWwwAuthenticatePayment(header: string | null | undefined): MppChallenge | null;
|
|
97
|
+
/**
|
|
98
|
+
* Parse an MPP `Accept-Payment` header per core spec §6.1.
|
|
99
|
+
*
|
|
100
|
+
* Grammar: `Accept-Payment = #(method-or-* "/" intent-or-* [weight])`.
|
|
101
|
+
* Drops malformed entries silently — spec §6.1: "If Accept-Payment is
|
|
102
|
+
* malformed, servers MAY ignore it." Stable sort: descending q, original
|
|
103
|
+
* order on ties (preserves client preference per §6.1).
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* const ranges = parseMppAcceptPayment('tempo/charge, tempo/session;q=0, stripe/*;q=0.5');
|
|
108
|
+
* // [{ method: 'tempo', intent: 'charge', q: 1 },
|
|
109
|
+
* // { method: 'stripe', intent: '*', q: 0.5 },
|
|
110
|
+
* // { method: 'tempo', intent: 'session', q: 0 }]
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function parseMppAcceptPayment(header: string | null | undefined): MppPaymentRange[];
|
|
114
|
+
/**
|
|
115
|
+
* Match an MPP payment range against a concrete `method/intent` pair.
|
|
116
|
+
*
|
|
117
|
+
* Returns a specificity score: 2 (exact match on both), 1 (one wildcard),
|
|
118
|
+
* 0 (both wildcards), -1 (no match). Higher specificity wins per spec §6.1:
|
|
119
|
+
* "Prefer the most specific matching range when multiple ranges match."
|
|
120
|
+
*/
|
|
121
|
+
declare function matchMppRange(range: MppPaymentRange, method: string, intent: string): number;
|
|
122
|
+
/**
|
|
123
|
+
* Decode the `request` parameter of an MPP Charge challenge into its shared
|
|
124
|
+
* fields. Per `draft-payment-intent-charge-00` §Request Schema, every Charge
|
|
125
|
+
* method emits `amount` + `currency` as REQUIRED shared fields; blockchain
|
|
126
|
+
* methods additionally require `recipient`.
|
|
127
|
+
*
|
|
128
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the request blob is not
|
|
129
|
+
* base64url-JSON, or is missing `amount` / `currency`, or if `amount` is
|
|
130
|
+
* not a non-negative integer string.
|
|
131
|
+
*/
|
|
132
|
+
declare function decodeMppChargeRequest(challenge: MppChallenge): MppChargeRequest;
|
|
133
|
+
/**
|
|
134
|
+
* Decode an `Authorization: Payment <base64url>` credential into its JSON form.
|
|
135
|
+
* Does not verify HMAC challenge-binding — that requires the server's secret
|
|
136
|
+
* and is intentionally out of scope for this client-facing helper.
|
|
137
|
+
*
|
|
138
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the header is missing/malformed,
|
|
139
|
+
* the blob is not base64url-JSON, or required fields (`challenge`, `payload`)
|
|
140
|
+
* are missing.
|
|
141
|
+
*/
|
|
142
|
+
declare function decodeMppCredential(authorizationHeader: string | null | undefined): MppCredential;
|
|
143
|
+
/**
|
|
144
|
+
* Translate an MPP Charge challenge into s402 requirements using the `exact`
|
|
145
|
+
* scheme. This is the inbound half of the coexistence pattern documented in
|
|
146
|
+
* `guide/upgrade-mpp.md`: an s402 client receives an MPP 402, lifts it into
|
|
147
|
+
* s402 types, then reuses its existing payment machinery.
|
|
148
|
+
*
|
|
149
|
+
* Only blockchain-like methods are translated here. Processor methods (Stripe
|
|
150
|
+
* card, etc.) route internally and do not expose the payTo/asset fields s402
|
|
151
|
+
* requires — keep those on the MPP path.
|
|
152
|
+
*
|
|
153
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the method is not a known
|
|
154
|
+
* blockchain-style Charge method, if the request is missing a recipient
|
|
155
|
+
* (REQUIRED for blockchain methods per charge spec), or if the challenge
|
|
156
|
+
* has expired at `now`.
|
|
157
|
+
*/
|
|
158
|
+
declare function fromMppChargeChallenge(challenge: MppChallenge, now?: number): s402PaymentRequirements;
|
|
159
|
+
//#endregion
|
|
160
|
+
export { MppChallenge, MppChargeRequest, MppCredential, MppPaymentRange, decodeMppChargeRequest, decodeMppCredential, fromMppChargeChallenge, matchMppRange, parseMppAcceptPayment, parseWwwAuthenticatePayment };
|