mppx 0.5.4 → 0.5.6
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 +14 -0
- package/dist/Html.d.ts +10 -0
- package/dist/Html.d.ts.map +1 -0
- package/dist/Html.js +41 -0
- package/dist/Html.js.map +1 -0
- package/dist/server/Mppx.d.ts +1 -28
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +101 -30
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +18 -46
- package/dist/server/Transport.js.map +1 -1
- package/dist/server/internal/html/compose.main.gen.d.ts +2 -0
- package/dist/server/internal/html/compose.main.gen.d.ts.map +1 -0
- package/dist/server/internal/html/compose.main.gen.js +3 -0
- package/dist/server/internal/html/compose.main.gen.js.map +1 -0
- package/dist/server/internal/html/config.d.ts +46 -49
- package/dist/server/internal/html/config.d.ts.map +1 -1
- package/dist/server/internal/html/config.js +323 -117
- package/dist/server/internal/html/config.js.map +1 -1
- package/dist/server/internal/html/constants.d.ts +26 -0
- package/dist/server/internal/html/constants.d.ts.map +1 -0
- package/dist/server/internal/html/constants.js +26 -0
- package/dist/server/internal/html/constants.js.map +1 -0
- package/dist/server/internal/html/serviceWorker.client.d.ts +2 -0
- package/dist/server/internal/html/serviceWorker.client.d.ts.map +1 -0
- package/dist/server/internal/html/serviceWorker.client.js +26 -0
- package/dist/server/internal/html/serviceWorker.client.js.map +1 -0
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Attribution.d.ts +24 -7
- package/dist/tempo/Attribution.d.ts.map +1 -1
- package/dist/tempo/Attribution.js +33 -7
- package/dist/tempo/Attribution.js.map +1 -1
- package/dist/tempo/client/Charge.js +1 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +36 -2
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +6 -1
- package/src/Html.ts +57 -0
- package/src/server/Mppx.test.ts +203 -0
- package/src/server/Mppx.ts +118 -3
- package/src/server/Transport.test.ts +5 -2
- package/src/server/Transport.ts +21 -54
- package/src/server/internal/html/compose.main.gen.ts +2 -0
- package/src/server/internal/html/compose.main.ts +88 -0
- package/src/server/internal/html/config.ts +422 -177
- package/src/server/internal/html/constants.ts +28 -0
- package/src/server/internal/html/serviceWorker.client.ts +2 -2
- package/src/server/internal/html/tsconfig.compose.json +8 -0
- package/src/stripe/server/internal/html/main.ts +44 -53
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Attribution.test.ts +129 -23
- package/src/tempo/Attribution.ts +39 -10
- package/src/tempo/client/Charge.ts +1 -1
- package/src/tempo/server/Charge.test.ts +205 -5
- package/src/tempo/server/Charge.ts +54 -3
- package/src/tempo/server/internal/html/main.ts +26 -28
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,q7+aAAq7+a,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mppx",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.6",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
@@ -75,6 +75,11 @@
|
|
|
75
75
|
"src": "./src/tempo/index.ts",
|
|
76
76
|
"default": "./dist/tempo/index.js"
|
|
77
77
|
},
|
|
78
|
+
"./html": {
|
|
79
|
+
"types": "./dist/Html.d.ts",
|
|
80
|
+
"src": "./src/Html.ts",
|
|
81
|
+
"default": "./dist/Html.js"
|
|
82
|
+
},
|
|
78
83
|
"./hono": {
|
|
79
84
|
"types": "./dist/middlewares/hono.d.ts",
|
|
80
85
|
"src": "./src/middlewares/hono.ts",
|
package/src/Html.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Json } from 'ox'
|
|
2
|
+
|
|
3
|
+
import type * as Method from './Method.js'
|
|
4
|
+
import { attrs, type Data, ids, vars } from './server/internal/html/config.js'
|
|
5
|
+
import { submitCredential } from './server/internal/html/serviceWorker.client.js'
|
|
6
|
+
|
|
7
|
+
export function init<
|
|
8
|
+
method extends Method.Method = Method.Method,
|
|
9
|
+
config extends Record<string, unknown> = {},
|
|
10
|
+
>(methodName: method['name']): Context<method, config> {
|
|
11
|
+
const element = document.getElementById(ids.data)!
|
|
12
|
+
const dataMap = Json.parse(element.textContent) as Record<string, Data<method, config>>
|
|
13
|
+
|
|
14
|
+
const remaining = element.getAttribute(attrs.remaining)
|
|
15
|
+
if (!remaining || Number(remaining) <= 1) element.remove()
|
|
16
|
+
else element.setAttribute(attrs.remaining, String(Number(remaining) - 1))
|
|
17
|
+
|
|
18
|
+
const script = document.currentScript
|
|
19
|
+
const challengeId = script?.getAttribute(attrs.challengeId)
|
|
20
|
+
const data = challengeId
|
|
21
|
+
? (script!.removeAttribute(attrs.challengeId), dataMap[challengeId]!)
|
|
22
|
+
: Object.values(dataMap).find((d) => d.challenge.method === methodName)!
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
...data,
|
|
26
|
+
error(message?: string | null | undefined) {
|
|
27
|
+
if (!message) {
|
|
28
|
+
document.getElementById(ids.error)?.remove()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const existing = document.getElementById(ids.error)
|
|
32
|
+
if (existing) {
|
|
33
|
+
existing.textContent = message
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
const el = document.createElement('p')
|
|
37
|
+
el.id = ids.error
|
|
38
|
+
el.className = 'mppx-error'
|
|
39
|
+
el.role = 'alert'
|
|
40
|
+
el.textContent = message
|
|
41
|
+
document.getElementById(data.rootId)?.after(el)
|
|
42
|
+
},
|
|
43
|
+
root: document.getElementById(data.rootId)!,
|
|
44
|
+
submit: submitCredential,
|
|
45
|
+
vars,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Context<
|
|
50
|
+
method extends Method.Method = Method.Method,
|
|
51
|
+
config extends Record<string, unknown> = {},
|
|
52
|
+
> = Data<method, config> & {
|
|
53
|
+
error: (message?: string | null | undefined) => void
|
|
54
|
+
root: HTMLElement
|
|
55
|
+
submit: (credential: string) => Promise<void>
|
|
56
|
+
vars: typeof vars
|
|
57
|
+
}
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -1184,6 +1184,209 @@ describe('compose', () => {
|
|
|
1184
1184
|
|
|
1185
1185
|
expect(result.status).toBe(200)
|
|
1186
1186
|
})
|
|
1187
|
+
|
|
1188
|
+
describe('html', () => {
|
|
1189
|
+
const htmlOptionsA = {
|
|
1190
|
+
config: { providerA: true },
|
|
1191
|
+
content: '<script src="/alpha-bundle.js"></script>',
|
|
1192
|
+
formatAmount: (request: Record<string, unknown>) => `$${request.amount}`,
|
|
1193
|
+
text: undefined,
|
|
1194
|
+
theme: undefined,
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const htmlOptionsB = {
|
|
1198
|
+
config: { providerB: true },
|
|
1199
|
+
content: '<script src="/beta-bundle.js"></script>',
|
|
1200
|
+
formatAmount: (request: Record<string, unknown>) => `$${request.amount}`,
|
|
1201
|
+
text: undefined,
|
|
1202
|
+
theme: undefined,
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const alphaWithHtml = Method.toServer(mockChargeA, {
|
|
1206
|
+
html: htmlOptionsA,
|
|
1207
|
+
async verify() {
|
|
1208
|
+
return mockReceipt('alpha')
|
|
1209
|
+
},
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
const betaWithHtml = Method.toServer(mockChargeB, {
|
|
1213
|
+
html: htmlOptionsB,
|
|
1214
|
+
async verify() {
|
|
1215
|
+
return mockReceipt('beta')
|
|
1216
|
+
},
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
test('returns html with tabs when multiple methods have html', async () => {
|
|
1220
|
+
const mppx = Mppx.create({
|
|
1221
|
+
methods: [alphaWithHtml, betaWithHtml],
|
|
1222
|
+
realm,
|
|
1223
|
+
secretKey,
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
|
|
1227
|
+
|
|
1228
|
+
const result = await handle(
|
|
1229
|
+
new Request('https://example.com/resource', {
|
|
1230
|
+
headers: { Accept: 'text/html' },
|
|
1231
|
+
}),
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
expect(result.status).toBe(402)
|
|
1235
|
+
if (result.status !== 402) throw new Error()
|
|
1236
|
+
|
|
1237
|
+
const body = await result.challenge.text()
|
|
1238
|
+
expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
|
1239
|
+
|
|
1240
|
+
// Tab a11y markup
|
|
1241
|
+
expect(body).toContain('role="tablist"')
|
|
1242
|
+
expect(body).toContain('role="tab"')
|
|
1243
|
+
expect(body).toContain('role="tabpanel"')
|
|
1244
|
+
expect(body).toContain('aria-selected="true"')
|
|
1245
|
+
expect(body).toContain('aria-controls="mppx-panel-0"')
|
|
1246
|
+
expect(body).toContain('aria-controls="mppx-panel-1"')
|
|
1247
|
+
|
|
1248
|
+
// Tab labels from method names (capitalized via CSS)
|
|
1249
|
+
expect(body).toContain('alpha')
|
|
1250
|
+
expect(body).toContain('beta')
|
|
1251
|
+
|
|
1252
|
+
// Both method bundles included
|
|
1253
|
+
expect(body).toContain('/alpha-bundle.js')
|
|
1254
|
+
expect(body).toContain('/beta-bundle.js')
|
|
1255
|
+
|
|
1256
|
+
// Data map with both entries
|
|
1257
|
+
const dataMatch = body.match(
|
|
1258
|
+
/<script[^>]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s,
|
|
1259
|
+
)
|
|
1260
|
+
expect(dataMatch).not.toBeNull()
|
|
1261
|
+
const dataMap = JSON.parse(dataMatch![1]!.replace(/\\u003c/g, '<'))
|
|
1262
|
+
const dataValues = Object.values(dataMap) as { label: string; config: unknown }[]
|
|
1263
|
+
expect(dataValues).toHaveLength(2)
|
|
1264
|
+
expect(dataValues[0]!.label).toBe('alpha')
|
|
1265
|
+
expect(dataValues[0]!.config).toEqual({ providerA: true })
|
|
1266
|
+
expect(dataValues[1]!.label).toBe('beta')
|
|
1267
|
+
expect(dataValues[1]!.config).toEqual({ providerB: true })
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
test('returns html without tabs when single method has html', async () => {
|
|
1271
|
+
const mppx = Mppx.create({
|
|
1272
|
+
methods: [alphaWithHtml, betaMethod],
|
|
1273
|
+
realm,
|
|
1274
|
+
secretKey,
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaMethod, challengeOpts])
|
|
1278
|
+
|
|
1279
|
+
const result = await handle(
|
|
1280
|
+
new Request('https://example.com/resource', {
|
|
1281
|
+
headers: { Accept: 'text/html' },
|
|
1282
|
+
}),
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
expect(result.status).toBe(402)
|
|
1286
|
+
if (result.status !== 402) throw new Error()
|
|
1287
|
+
|
|
1288
|
+
const body = await result.challenge.text()
|
|
1289
|
+
expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
|
1290
|
+
|
|
1291
|
+
// No tabs when only one method has html
|
|
1292
|
+
expect(body).not.toContain('role="tablist"')
|
|
1293
|
+
expect(body).not.toContain('role="tab"')
|
|
1294
|
+
|
|
1295
|
+
// Single panel present
|
|
1296
|
+
expect(body).toContain('mppx-panel-0')
|
|
1297
|
+
expect(body).toContain('/alpha-bundle.js')
|
|
1298
|
+
|
|
1299
|
+
// Data map with single entry
|
|
1300
|
+
const dataMatch = body.match(
|
|
1301
|
+
/<script[^>]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s,
|
|
1302
|
+
)
|
|
1303
|
+
const dataMap = JSON.parse(dataMatch![1]!.replace(/\\u003c/g, '<'))
|
|
1304
|
+
const dataValues = Object.values(dataMap) as { label: string }[]
|
|
1305
|
+
expect(dataValues).toHaveLength(1)
|
|
1306
|
+
expect(dataValues[0]!.label).toBe('alpha')
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
test('falls back to json when Accept does not include text/html', async () => {
|
|
1310
|
+
const mppx = Mppx.create({
|
|
1311
|
+
methods: [alphaWithHtml, betaWithHtml],
|
|
1312
|
+
realm,
|
|
1313
|
+
secretKey,
|
|
1314
|
+
})
|
|
1315
|
+
|
|
1316
|
+
const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
|
|
1317
|
+
|
|
1318
|
+
const result = await handle(new Request('https://example.com/resource'))
|
|
1319
|
+
|
|
1320
|
+
expect(result.status).toBe(402)
|
|
1321
|
+
if (result.status !== 402) throw new Error()
|
|
1322
|
+
|
|
1323
|
+
const contentType = result.challenge.headers.get('Content-Type')
|
|
1324
|
+
expect(contentType).not.toContain('text/html')
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
test('serves service worker when __mppx_worker param is set', async () => {
|
|
1328
|
+
const mppx = Mppx.create({
|
|
1329
|
+
methods: [alphaWithHtml, betaWithHtml],
|
|
1330
|
+
realm,
|
|
1331
|
+
secretKey,
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
|
|
1335
|
+
|
|
1336
|
+
const result = await handle(new Request('https://example.com/resource?__mppx_worker'))
|
|
1337
|
+
|
|
1338
|
+
expect(result.status).toBe(402)
|
|
1339
|
+
if (result.status !== 402) throw new Error()
|
|
1340
|
+
|
|
1341
|
+
expect(result.challenge.status).toBe(200)
|
|
1342
|
+
expect(result.challenge.headers.get('Content-Type')).toBe('application/javascript')
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
test('returns json when no methods have html configured', async () => {
|
|
1346
|
+
const mppx = Mppx.create({
|
|
1347
|
+
methods: [alphaMethod, betaMethod],
|
|
1348
|
+
realm,
|
|
1349
|
+
secretKey,
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
const handle = mppx.compose([alphaMethod, challengeOpts], [betaMethod, challengeOpts])
|
|
1353
|
+
|
|
1354
|
+
const result = await handle(
|
|
1355
|
+
new Request('https://example.com/resource', {
|
|
1356
|
+
headers: { Accept: 'text/html' },
|
|
1357
|
+
}),
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
expect(result.status).toBe(402)
|
|
1361
|
+
if (result.status !== 402) throw new Error()
|
|
1362
|
+
|
|
1363
|
+
const contentType = result.challenge.headers.get('Content-Type')
|
|
1364
|
+
expect(contentType).not.toContain('text/html')
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
test('both WWW-Authenticate headers present even with html', async () => {
|
|
1368
|
+
const mppx = Mppx.create({
|
|
1369
|
+
methods: [alphaWithHtml, betaWithHtml],
|
|
1370
|
+
realm,
|
|
1371
|
+
secretKey,
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
|
|
1375
|
+
|
|
1376
|
+
const result = await handle(
|
|
1377
|
+
new Request('https://example.com/resource', {
|
|
1378
|
+
headers: { Accept: 'text/html' },
|
|
1379
|
+
}),
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
expect(result.status).toBe(402)
|
|
1383
|
+
if (result.status !== 402) throw new Error()
|
|
1384
|
+
|
|
1385
|
+
const wwwAuth = result.challenge.headers.get('WWW-Authenticate')!
|
|
1386
|
+
expect(wwwAuth).toContain('method="alpha"')
|
|
1387
|
+
expect(wwwAuth).toContain('method="beta"')
|
|
1388
|
+
})
|
|
1389
|
+
})
|
|
1187
1390
|
})
|
|
1188
1391
|
|
|
1189
1392
|
describe('compose: pre-dispatch narrowing edge cases', () => {
|
package/src/server/Mppx.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type * as Method from '../Method.js'
|
|
|
10
10
|
import * as PaymentRequest from '../PaymentRequest.js'
|
|
11
11
|
import type * as Receipt from '../Receipt.js'
|
|
12
12
|
import type * as z from '../zod.js'
|
|
13
|
+
import * as Html from './internal/html/config.js'
|
|
14
|
+
import { serviceWorker } from './internal/html/serviceWorker.gen.js'
|
|
13
15
|
import * as NodeListener from './NodeListener.js'
|
|
14
16
|
import * as Request from './Request.js'
|
|
15
17
|
import * as Transport from './Transport.js'
|
|
@@ -645,6 +647,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
|
|
|
645
647
|
_internal: {
|
|
646
648
|
name: string
|
|
647
649
|
intent: string
|
|
650
|
+
html: Html.Options | undefined
|
|
648
651
|
_canonicalRequest: Record<string, unknown>
|
|
649
652
|
}
|
|
650
653
|
}
|
|
@@ -697,12 +700,52 @@ type ComposeEntry<methods extends readonly Method.AnyServer[]> =
|
|
|
697
700
|
* })
|
|
698
701
|
* ```
|
|
699
702
|
*/
|
|
703
|
+
type ComposeHtmlOptions = { theme?: Html.Theme; text?: Html.Text }
|
|
704
|
+
|
|
700
705
|
export function compose(
|
|
701
|
-
...
|
|
706
|
+
...args: readonly unknown[]
|
|
702
707
|
): (input: Request) => Promise<MethodFn.Response<Transport.Http>> {
|
|
708
|
+
// Extract optional html options from last argument
|
|
709
|
+
const last = args[args.length - 1]
|
|
710
|
+
const composeOptions: Html.Options | undefined =
|
|
711
|
+
typeof last === 'object' &&
|
|
712
|
+
last !== null &&
|
|
713
|
+
typeof last !== 'function' &&
|
|
714
|
+
!('_internal' in last)
|
|
715
|
+
? (() => {
|
|
716
|
+
const opts = last as ComposeHtmlOptions
|
|
717
|
+
return {
|
|
718
|
+
config: {},
|
|
719
|
+
content: '',
|
|
720
|
+
formatAmount: () => '',
|
|
721
|
+
text: opts.text,
|
|
722
|
+
theme: opts.theme,
|
|
723
|
+
}
|
|
724
|
+
})()
|
|
725
|
+
: undefined
|
|
726
|
+
const handlers = (composeOptions ? args.slice(0, -1) : args) as readonly ((
|
|
727
|
+
input: Request,
|
|
728
|
+
) => Promise<MethodFn.Response<Transport.Http>>)[]
|
|
729
|
+
|
|
703
730
|
if (handlers.length === 0) throw new Error('compose() requires at least one handler')
|
|
704
731
|
|
|
705
732
|
return async (input: Request) => {
|
|
733
|
+
// Serve service worker for html-enabled compose
|
|
734
|
+
if (new URL(input.url).searchParams.has(Html.params.serviceWorker)) {
|
|
735
|
+
const hasHtml = handlers.some((h) => (h as ConfiguredHandler)._internal?.html)
|
|
736
|
+
if (hasHtml)
|
|
737
|
+
return {
|
|
738
|
+
status: 402,
|
|
739
|
+
challenge: new Response(serviceWorker, {
|
|
740
|
+
status: 200,
|
|
741
|
+
headers: {
|
|
742
|
+
'Content-Type': 'application/javascript',
|
|
743
|
+
'Cache-Control': 'no-store',
|
|
744
|
+
},
|
|
745
|
+
}),
|
|
746
|
+
} as MethodFn.Response<Transport.Http>
|
|
747
|
+
}
|
|
748
|
+
|
|
706
749
|
// Try to extract a Payment credential to decide whether to dispatch or challenge.
|
|
707
750
|
// Only gate on the Payment scheme — other auth schemes (Bearer, Basic, etc.)
|
|
708
751
|
// should fall through to the merged-402 path so all offers are presented.
|
|
@@ -754,17 +797,89 @@ export function compose(
|
|
|
754
797
|
const mergedHeaders = new Headers()
|
|
755
798
|
mergedHeaders.set('Cache-Control', 'no-store')
|
|
756
799
|
|
|
757
|
-
let body: string | null = null
|
|
758
800
|
for (const result of results) {
|
|
759
801
|
if (result.status !== 402) continue
|
|
760
802
|
const response = result.challenge as Response
|
|
761
803
|
const wwwAuth = response.headers.get('WWW-Authenticate')
|
|
762
804
|
if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth)
|
|
763
|
-
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Collect html-enabled handlers and their challenges
|
|
808
|
+
const htmlEntries = (() => {
|
|
809
|
+
const entries: {
|
|
810
|
+
handler: ConfiguredHandler
|
|
811
|
+
challenge: Challenge.Challenge
|
|
812
|
+
}[] = []
|
|
813
|
+
for (let i = 0; i < handlers.length; i++) {
|
|
814
|
+
const meta = (handlers[i] as ConfiguredHandler)._internal
|
|
815
|
+
if (!meta?.html) continue
|
|
816
|
+
const result = results[i]
|
|
817
|
+
if (result?.status !== 402) continue
|
|
818
|
+
const wwwAuth = result.challenge.headers.get('WWW-Authenticate')
|
|
819
|
+
if (!wwwAuth) continue
|
|
820
|
+
entries.push({
|
|
821
|
+
handler: handlers[i] as ConfiguredHandler,
|
|
822
|
+
challenge: Challenge.deserialize(wwwAuth),
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
return entries
|
|
826
|
+
})()
|
|
827
|
+
|
|
828
|
+
const wantsHtml = input.headers.get('Accept')?.includes('text/html')
|
|
829
|
+
if (wantsHtml && htmlEntries.length > 0) {
|
|
830
|
+
const { theme, text } = Html.resolveOptions(
|
|
831
|
+
// Use compose-level options or first html-enabled method's config for the page shell
|
|
832
|
+
composeOptions ?? htmlEntries[0]?.handler._internal.html ?? ({} as Html.Options),
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
// Build data map keyed by challenge.id
|
|
836
|
+
const dataMap: Record<string, Html.Data> = {}
|
|
837
|
+
for (let i = 0; i < htmlEntries.length; i++) {
|
|
838
|
+
const entry = htmlEntries[i]!
|
|
839
|
+
dataMap[entry.challenge.id] = {
|
|
840
|
+
label: entry.handler._internal.name,
|
|
841
|
+
rootId: `${Html.ids.root}-${i}`,
|
|
842
|
+
formattedAmount: await entry.handler._internal.html!.formatAmount(
|
|
843
|
+
entry.challenge.request,
|
|
844
|
+
),
|
|
845
|
+
config: entry.handler._internal.html!.config,
|
|
846
|
+
challenge: entry.challenge as never,
|
|
847
|
+
text,
|
|
848
|
+
theme,
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
mergedHeaders.set('Content-Type', 'text/html; charset=utf-8')
|
|
853
|
+
|
|
854
|
+
const firstData = Object.values(dataMap)[0]!
|
|
855
|
+
const body = Html.render({
|
|
856
|
+
entries: htmlEntries.map((entry) => ({
|
|
857
|
+
challenge: entry.challenge,
|
|
858
|
+
content: entry.handler._internal.html!.content,
|
|
859
|
+
})),
|
|
860
|
+
dataMap,
|
|
861
|
+
formattedAmount: firstData.formattedAmount,
|
|
862
|
+
panels: true,
|
|
863
|
+
text,
|
|
864
|
+
theme,
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
status: 402,
|
|
869
|
+
challenge: new Response(body, { status: 402, headers: mergedHeaders }),
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Non-HTML fallback: use first handler's body
|
|
874
|
+
let body: string | null = null
|
|
875
|
+
for (const result of results) {
|
|
876
|
+
if (result.status !== 402) continue
|
|
764
877
|
if (!body) {
|
|
878
|
+
const response = result.challenge as Response
|
|
765
879
|
const contentType = response.headers.get('Content-Type')
|
|
766
880
|
if (contentType) mergedHeaders.set('Content-Type', contentType)
|
|
767
881
|
body = await response.text()
|
|
882
|
+
break
|
|
768
883
|
}
|
|
769
884
|
}
|
|
770
885
|
|
|
@@ -285,11 +285,14 @@ describe('http', () => {
|
|
|
285
285
|
const body = await response.text()
|
|
286
286
|
// Extract the JSON data from the script tag
|
|
287
287
|
const dataMatch = body.match(
|
|
288
|
-
/<script
|
|
288
|
+
/<script[^>]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s,
|
|
289
289
|
)
|
|
290
290
|
expect(dataMatch).not.toBeNull()
|
|
291
291
|
|
|
292
|
-
const
|
|
292
|
+
const dataMap = JSON.parse(dataMatch?.[1]?.replace(/\\u003c/g, '<') ?? '')
|
|
293
|
+
expect(typeof dataMap).toBe('object')
|
|
294
|
+
expect(Object.keys(dataMap)).toHaveLength(1)
|
|
295
|
+
const data = dataMap[challenge.id]
|
|
293
296
|
expect(data.config).toEqual({ foo: 'bar' })
|
|
294
297
|
expect(data.challenge.id).toBe(challenge.id)
|
|
295
298
|
expect(data.challenge.method).toBe('tempo')
|
package/src/server/Transport.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Json } from 'ox'
|
|
2
|
-
|
|
3
1
|
import * as Challenge from '../Challenge.js'
|
|
4
2
|
import * as Credential from '../Credential.js'
|
|
5
3
|
import * as Errors from '../Errors.js'
|
|
@@ -7,7 +5,6 @@ import type { Distribute, UnionToIntersection } from '../internal/types.js'
|
|
|
7
5
|
import * as core_Mcp from '../Mcp.js'
|
|
8
6
|
import * as Receipt from '../Receipt.js'
|
|
9
7
|
import * as Html from './internal/html/config.js'
|
|
10
|
-
import { html } from './internal/html/config.js'
|
|
11
8
|
import { serviceWorker } from './internal/html/serviceWorker.gen.js'
|
|
12
9
|
|
|
13
10
|
export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js'
|
|
@@ -132,7 +129,7 @@ export function http(): Http {
|
|
|
132
129
|
async respondChallenge(options) {
|
|
133
130
|
const { challenge, error, input } = options
|
|
134
131
|
|
|
135
|
-
if (options.html && new URL(input.url).searchParams.has(Html.
|
|
132
|
+
if (options.html && new URL(input.url).searchParams.has(Html.params.serviceWorker))
|
|
136
133
|
return new Response(serviceWorker, {
|
|
137
134
|
status: 200,
|
|
138
135
|
headers: {
|
|
@@ -150,58 +147,28 @@ export function http(): Http {
|
|
|
150
147
|
if (options.html && input.headers.get('Accept')?.includes('text/html')) {
|
|
151
148
|
headers['Content-Type'] = 'text/html; charset=utf-8'
|
|
152
149
|
|
|
153
|
-
const theme = Html.
|
|
154
|
-
{
|
|
155
|
-
favicon: undefined as Html.Theme['favicon'],
|
|
156
|
-
fontUrl: undefined as Html.Theme['fontUrl'],
|
|
157
|
-
logo: undefined as Html.Theme['logo'],
|
|
158
|
-
...Html.defaultTheme,
|
|
159
|
-
},
|
|
160
|
-
(options.html.theme as never) ?? {},
|
|
161
|
-
)
|
|
162
|
-
const text = Html.sanitizeRecord(
|
|
163
|
-
Html.mergeDefined(Html.defaultText, (options.html.text as never) ?? {}),
|
|
164
|
-
)
|
|
150
|
+
const { theme, text } = Html.resolveOptions(options.html)
|
|
165
151
|
const amount = await options.html.formatAmount(challenge.request)
|
|
166
152
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
? `<p class="${Html.classNames.summaryDescription}">${Html.sanitize(challenge.description)}</p>`
|
|
187
|
-
: ''}
|
|
188
|
-
${challenge.expires
|
|
189
|
-
? `<p class="${Html.classNames.summaryExpires}">${text.expires} <time datetime="${new Date(challenge.expires).toISOString()}">${new Date(challenge.expires).toLocaleString()}</time></p>`
|
|
190
|
-
: ''}
|
|
191
|
-
</section>
|
|
192
|
-
<div id="${Html.rootId}" aria-label="Payment form"></div>
|
|
193
|
-
<script id="${Html.dataId}" type="application/json">
|
|
194
|
-
${Json.stringify({
|
|
195
|
-
config: options.html.config,
|
|
196
|
-
challenge,
|
|
197
|
-
text,
|
|
198
|
-
theme,
|
|
199
|
-
} satisfies Html.Data).replace(/</g, '\\u003c')}
|
|
200
|
-
</script>
|
|
201
|
-
${options.html.content}
|
|
202
|
-
</main>
|
|
203
|
-
</body>
|
|
204
|
-
</html> `
|
|
153
|
+
const dataMap = {
|
|
154
|
+
[challenge.id]: {
|
|
155
|
+
label: challenge.method,
|
|
156
|
+
rootId: Html.ids.root,
|
|
157
|
+
formattedAmount: amount,
|
|
158
|
+
config: options.html.config,
|
|
159
|
+
challenge,
|
|
160
|
+
text,
|
|
161
|
+
theme,
|
|
162
|
+
},
|
|
163
|
+
} satisfies Record<string, Html.Data>
|
|
164
|
+
|
|
165
|
+
return Html.render({
|
|
166
|
+
entries: [{ challenge, content: options.html.content }],
|
|
167
|
+
dataMap,
|
|
168
|
+
formattedAmount: amount,
|
|
169
|
+
text,
|
|
170
|
+
theme,
|
|
171
|
+
})
|
|
205
172
|
}
|
|
206
173
|
if (error) {
|
|
207
174
|
headers['Content-Type'] = 'application/problem+json'
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated — do not edit.
|
|
2
|
+
export const tabScript = "<script>(function(){let e={serviceWorker:`__mppx_worker`,tab:`__mppx_tab`},t={error:`mppx-error`,header:`mppx-header`,logo:`mppx-logo`,summary:`mppx-summary`,summaryAmount:`mppx-summary-amount`,summaryDescription:`mppx-summary-description`,summaryExpires:`mppx-summary-expires`,tab:`mppx-tab`,tabList:`mppx-tablist`,tabPanel:`mppx-tabpanel`},n=document.querySelector(`.${t.tabList}`),r=document.querySelector(`.${t.summary}`),i=r.querySelector(`.${t.summaryAmount}`),a=Array.from(n.querySelectorAll(`[role=\"tab\"]`)),o=[],s={};for(let e of a){let t=e.textContent.trim().toLowerCase();s[t]=(s[t]||0)+1,o.push(s[t]===1?t:`${t}-${s[t]}`)}function c(e){if(i.textContent=e.dataset.amount,r.querySelector(`.${t.summaryDescription}`)?.remove(),e.dataset.description){let n=document.createElement(`p`);n.className=t.summaryDescription,n.textContent=e.dataset.description,i.after(n)}if(r.querySelector(`.${t.summaryExpires}`)?.remove(),e.dataset.expires){let n=document.createElement(`p`);n.className=t.summaryExpires;let i=new Date(e.dataset.expires),a=document.createElement(`time`);a.dateTime=i.toISOString(),a.textContent=i.toLocaleString(),n.textContent=`${e.dataset.expiresLabel} `,n.appendChild(a),r.appendChild(n)}}function l(t,n=!0){if(a.forEach(e=>{e.setAttribute(`aria-selected`,`false`),e.setAttribute(`tabindex`,`-1`)}),t.setAttribute(`aria-selected`,`true`),t.removeAttribute(`tabindex`),t.focus(),document.querySelectorAll(`[role=\"tabpanel\"]`).forEach(e=>{e.hidden=!0}),document.getElementById(t.getAttribute(`aria-controls`)).hidden=!1,c(t),n){let n=new URL(location.href);n.searchParams.set(e.tab,o[a.indexOf(t)]),history.replaceState(null,``,n)}}let u=new URL(location.href).searchParams.get(e.tab);if(u!==null){let e=o.indexOf(u);e>=0&&l(a[e],!1)}n.addEventListener(`click`,e=>{let t=e.target.closest(`[role=\"tab\"]`);t&&l(t)}),n.addEventListener(`keydown`,e=>{let t=a.indexOf(e.target);if(t<0)return;let n;e.key===`ArrowRight`?n=a[(t+1)%a.length]:e.key===`ArrowLeft`?n=a[(t-1+a.length)%a.length]:e.key===`Home`?n=a[0]:e.key===`End`&&(n=a[a.length-1]),n&&(e.preventDefault(),l(n))})})();</script>"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { classNames, params } from './constants.js'
|
|
2
|
+
|
|
3
|
+
const tablist = document.querySelector<HTMLElement>(`.${classNames.tabList}`)!
|
|
4
|
+
const summary = document.querySelector<HTMLElement>(`.${classNames.summary}`)!
|
|
5
|
+
const amount = summary.querySelector<HTMLElement>(`.${classNames.summaryAmount}`)!
|
|
6
|
+
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
|
|
7
|
+
|
|
8
|
+
// Generate unique slugs: tempo, stripe, stripe-2
|
|
9
|
+
const slugs: string[] = []
|
|
10
|
+
const counts: Record<string, number> = {}
|
|
11
|
+
for (const tab of tabs) {
|
|
12
|
+
const name = tab.textContent!.trim().toLowerCase()
|
|
13
|
+
counts[name] = (counts[name] || 0) + 1
|
|
14
|
+
slugs.push(counts[name] === 1 ? name : `${name}-${counts[name]}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function updateSummary(tab: HTMLElement) {
|
|
18
|
+
amount.textContent = tab.dataset.amount!
|
|
19
|
+
|
|
20
|
+
summary.querySelector(`.${classNames.summaryDescription}`)?.remove()
|
|
21
|
+
if (tab.dataset.description) {
|
|
22
|
+
const p = document.createElement('p')
|
|
23
|
+
p.className = classNames.summaryDescription
|
|
24
|
+
p.textContent = tab.dataset.description
|
|
25
|
+
amount.after(p)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
summary.querySelector(`.${classNames.summaryExpires}`)?.remove()
|
|
29
|
+
if (tab.dataset.expires) {
|
|
30
|
+
const p = document.createElement('p')
|
|
31
|
+
p.className = classNames.summaryExpires
|
|
32
|
+
const date = new Date(tab.dataset.expires)
|
|
33
|
+
const time = document.createElement('time')
|
|
34
|
+
time.dateTime = date.toISOString()
|
|
35
|
+
time.textContent = date.toLocaleString()
|
|
36
|
+
p.textContent = `${tab.dataset.expiresLabel} `
|
|
37
|
+
p.appendChild(time)
|
|
38
|
+
summary.appendChild(p)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function activate(tab: HTMLElement, updateUrl = true) {
|
|
43
|
+
tabs.forEach((t) => {
|
|
44
|
+
t.setAttribute('aria-selected', 'false')
|
|
45
|
+
t.setAttribute('tabindex', '-1')
|
|
46
|
+
})
|
|
47
|
+
tab.setAttribute('aria-selected', 'true')
|
|
48
|
+
tab.removeAttribute('tabindex')
|
|
49
|
+
tab.focus()
|
|
50
|
+
document.querySelectorAll<HTMLElement>('[role="tabpanel"]').forEach((p) => {
|
|
51
|
+
p.hidden = true
|
|
52
|
+
})
|
|
53
|
+
document.getElementById(tab.getAttribute('aria-controls')!)!.hidden = false
|
|
54
|
+
|
|
55
|
+
updateSummary(tab)
|
|
56
|
+
|
|
57
|
+
if (updateUrl) {
|
|
58
|
+
const url = new URL(location.href)
|
|
59
|
+
url.searchParams.set(params.tab, slugs[tabs.indexOf(tab)]!)
|
|
60
|
+
history.replaceState(null, '', url)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Restore tab from URL on load
|
|
65
|
+
const initial = new URL(location.href).searchParams.get(params.tab)
|
|
66
|
+
if (initial !== null) {
|
|
67
|
+
const index = slugs.indexOf(initial)
|
|
68
|
+
if (index >= 0) activate(tabs[index]!, false)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
tablist.addEventListener('click', (event) => {
|
|
72
|
+
const tab = (event.target as HTMLElement).closest<HTMLElement>('[role="tab"]')
|
|
73
|
+
if (tab) activate(tab)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
tablist.addEventListener('keydown', (event) => {
|
|
77
|
+
const index = tabs.indexOf(event.target as HTMLElement)
|
|
78
|
+
if (index < 0) return
|
|
79
|
+
let next: HTMLElement | undefined
|
|
80
|
+
if (event.key === 'ArrowRight') next = tabs[(index + 1) % tabs.length]
|
|
81
|
+
else if (event.key === 'ArrowLeft') next = tabs[(index - 1 + tabs.length) % tabs.length]
|
|
82
|
+
else if (event.key === 'Home') next = tabs[0]
|
|
83
|
+
else if (event.key === 'End') next = tabs[tabs.length - 1]
|
|
84
|
+
if (next) {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
activate(next)
|
|
87
|
+
}
|
|
88
|
+
})
|