@executor-js/emulate 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1044 -0
- package/dist/api.d.ts +24 -0
- package/dist/api.js +2665 -0
- package/dist/api.js.map +1 -0
- package/dist/chunk-D6EKRYGP.js +1615 -0
- package/dist/chunk-D6EKRYGP.js.map +1 -0
- package/dist/chunk-WVQMFHQM.js +83 -0
- package/dist/chunk-WVQMFHQM.js.map +1 -0
- package/dist/dist-7FDUSG5I.js +24368 -0
- package/dist/dist-7FDUSG5I.js.map +1 -0
- package/dist/dist-7N4COJHK.js +1814 -0
- package/dist/dist-7N4COJHK.js.map +1 -0
- package/dist/dist-BTEY33DJ.js +2334 -0
- package/dist/dist-BTEY33DJ.js.map +1 -0
- package/dist/dist-DK26ESP2.js +595 -0
- package/dist/dist-DK26ESP2.js.map +1 -0
- package/dist/dist-IYZPDKJW.js +1284 -0
- package/dist/dist-IYZPDKJW.js.map +1 -0
- package/dist/dist-JJ2ZRCAX.js +189 -0
- package/dist/dist-JJ2ZRCAX.js.map +1 -0
- package/dist/dist-K4CVTD6K.js +1570 -0
- package/dist/dist-K4CVTD6K.js.map +1 -0
- package/dist/dist-M3GVASMR.js +1254 -0
- package/dist/dist-M3GVASMR.js.map +1 -0
- package/dist/dist-OYYGWKZQ.js +1533 -0
- package/dist/dist-OYYGWKZQ.js.map +1 -0
- package/dist/dist-P3SBBRFR.js +3169 -0
- package/dist/dist-P3SBBRFR.js.map +1 -0
- package/dist/dist-RMPDKZUA.js +1183 -0
- package/dist/dist-RMPDKZUA.js.map +1 -0
- package/dist/dist-WBKONLOE.js +2154 -0
- package/dist/dist-WBKONLOE.js.map +1 -0
- package/dist/dist-XM5HSBDC.js +1090 -0
- package/dist/dist-XM5HSBDC.js.map +1 -0
- package/dist/dist-XVVIYXQG.js +4241 -0
- package/dist/dist-XVVIYXQG.js.map +1 -0
- package/dist/dist-YPRJYQHW.js +5109 -0
- package/dist/dist-YPRJYQHW.js.map +1 -0
- package/dist/dist-ZEC77OKZ.js +913 -0
- package/dist/dist-ZEC77OKZ.js.map +1 -0
- package/dist/fonts/GeistPixel-Square.woff2 +0 -0
- package/dist/fonts/favicon.ico +0 -0
- package/dist/fonts/geist-sans.woff2 +0 -0
- package/dist/helpers-LXLP3DFE-LBOTATT5.js +17 -0
- package/dist/helpers-LXLP3DFE-LBOTATT5.js.map +1 -0
- package/dist/index.js +3005 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
// ../@emulators/x/dist/index.js
|
|
2
|
+
import { createHash, randomBytes } from "crypto";
|
|
3
|
+
import { timingSafeEqual } from "crypto";
|
|
4
|
+
function getXStore(store) {
|
|
5
|
+
return {
|
|
6
|
+
users: store.collection("x.users", ["user_id", "username"]),
|
|
7
|
+
tweets: store.collection("x.tweets", ["tweet_id", "author_id"]),
|
|
8
|
+
oauthClients: store.collection("x.oauth_clients", ["client_id"]),
|
|
9
|
+
authCodes: store.collection("x.auth_codes", ["code", "client_id"]),
|
|
10
|
+
accessTokens: store.collection("x.access_tokens", ["token", "client_id"]),
|
|
11
|
+
refreshTokens: store.collection("x.refresh_tokens", ["token", "client_id"])
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function lookupAccessToken(store, token) {
|
|
15
|
+
const xs = getXStore(store);
|
|
16
|
+
const row = xs.accessTokens.findOneBy("token", token);
|
|
17
|
+
if (!row) return void 0;
|
|
18
|
+
if (row.expires > 0 && Date.now() > row.expires) {
|
|
19
|
+
xs.accessTokens.delete(row.id);
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
return row;
|
|
23
|
+
}
|
|
24
|
+
function xNumericId() {
|
|
25
|
+
let s = "";
|
|
26
|
+
for (let i = 0; i < 19; i++) {
|
|
27
|
+
s += Math.floor(Math.random() * 10).toString();
|
|
28
|
+
}
|
|
29
|
+
return s[0] === "0" ? "1" + s.slice(1) : s;
|
|
30
|
+
}
|
|
31
|
+
function createErrorHandler(documentationUrl) {
|
|
32
|
+
return async (c, next) => {
|
|
33
|
+
if (documentationUrl) {
|
|
34
|
+
c.set("docsUrl", documentationUrl);
|
|
35
|
+
}
|
|
36
|
+
await next();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
var errorHandler = createErrorHandler();
|
|
40
|
+
var isDebug = typeof process !== "undefined" && (process.env.DEBUG === "1" || process.env.DEBUG === "true" || process.env.EMULATE_DEBUG === "1");
|
|
41
|
+
function debug(label, ...args) {
|
|
42
|
+
if (isDebug) {
|
|
43
|
+
console.log(`[${label}]`, ...args);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function escapeHtml(s) {
|
|
47
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
48
|
+
}
|
|
49
|
+
function escapeAttr(s) {
|
|
50
|
+
return escapeHtml(s).replace(/'/g, "'");
|
|
51
|
+
}
|
|
52
|
+
var CSS = `
|
|
53
|
+
@font-face{
|
|
54
|
+
font-family:'Geist';font-style:normal;font-weight:100 900;font-display:swap;
|
|
55
|
+
src:url('/_emulate/fonts/geist-sans.woff2') format('woff2');
|
|
56
|
+
}
|
|
57
|
+
@font-face{
|
|
58
|
+
font-family:'Geist Pixel';font-style:normal;font-weight:400;font-display:swap;
|
|
59
|
+
src:url('/_emulate/fonts/GeistPixel-Square.woff2') format('woff2');
|
|
60
|
+
}
|
|
61
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
62
|
+
body{
|
|
63
|
+
font-family:'Geist',-apple-system,BlinkMacSystemFont,sans-serif;
|
|
64
|
+
background:#000;color:#33ff00;min-height:100vh;
|
|
65
|
+
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
|
|
66
|
+
}
|
|
67
|
+
.emu-bar{
|
|
68
|
+
border-bottom:1px solid #0a3300;padding:10px 20px;
|
|
69
|
+
display:flex;align-items:center;gap:10px;font-size:.8125rem;color:#1a8c00;
|
|
70
|
+
}
|
|
71
|
+
.emu-bar-title{font-weight:600;color:#33ff00;font-family:'Geist Pixel',monospace;}
|
|
72
|
+
.emu-bar-links{margin-left:auto;display:flex;gap:16px;}
|
|
73
|
+
.emu-bar-links a{
|
|
74
|
+
color:#1a8c00;font-size:.75rem;text-decoration:none;transition:color .15s;
|
|
75
|
+
}
|
|
76
|
+
.emu-bar-links a:hover{color:#33ff00;}
|
|
77
|
+
.emu-bar-links a .full{display:inline;}
|
|
78
|
+
.emu-bar-links a .short{display:none;}
|
|
79
|
+
@media(max-width:600px){
|
|
80
|
+
.emu-bar-links a .full{display:none;}
|
|
81
|
+
.emu-bar-links a .short{display:inline;}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.content{
|
|
85
|
+
display:flex;align-items:center;justify-content:center;
|
|
86
|
+
min-height:calc(100vh - 42px);padding:24px 16px;
|
|
87
|
+
}
|
|
88
|
+
.content-inner{width:100%;max-width:420px;}
|
|
89
|
+
.card-title{
|
|
90
|
+
font-family:'Geist Pixel',monospace;
|
|
91
|
+
font-size:1.125rem;font-weight:600;margin-bottom:4px;color:#33ff00;
|
|
92
|
+
}
|
|
93
|
+
.card-subtitle{color:#1a8c00;font-size:.8125rem;margin-bottom:18px;line-height:1.45;}
|
|
94
|
+
.powered-by{
|
|
95
|
+
position:fixed;bottom:0;left:0;right:0;
|
|
96
|
+
text-align:center;padding:12px;font-size:.6875rem;color:#0a3300;
|
|
97
|
+
font-family:'Geist Pixel',monospace;
|
|
98
|
+
}
|
|
99
|
+
.powered-by a{color:#1a8c00;text-decoration:none;transition:color .15s;}
|
|
100
|
+
.powered-by a:hover{color:#33ff00;}
|
|
101
|
+
|
|
102
|
+
.error-title{
|
|
103
|
+
font-family:'Geist Pixel',monospace;
|
|
104
|
+
color:#ff4444;font-size:1.125rem;font-weight:600;margin-bottom:8px;
|
|
105
|
+
}
|
|
106
|
+
.error-msg{color:#1a8c00;font-size:.875rem;line-height:1.5;}
|
|
107
|
+
.error-card{text-align:center;}
|
|
108
|
+
|
|
109
|
+
.user-form{margin-bottom:8px;}
|
|
110
|
+
.user-form:last-of-type{margin-bottom:0;}
|
|
111
|
+
.user-btn{
|
|
112
|
+
width:100%;display:flex;align-items:center;gap:12px;
|
|
113
|
+
padding:10px 12px;border:1px solid #0a3300;border-radius:8px;
|
|
114
|
+
background:#000;color:inherit;cursor:pointer;text-align:left;
|
|
115
|
+
font:inherit;transition:border-color .15s;
|
|
116
|
+
}
|
|
117
|
+
.user-btn:hover{border-color:#33ff00;}
|
|
118
|
+
.avatar{
|
|
119
|
+
width:36px;height:36px;border-radius:50%;
|
|
120
|
+
background:#0a3300;color:#33ff00;font-weight:600;font-size:.875rem;
|
|
121
|
+
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
|
122
|
+
font-family:'Geist Pixel',monospace;
|
|
123
|
+
}
|
|
124
|
+
.user-text{min-width:0;}
|
|
125
|
+
.user-login{font-weight:600;font-size:.875rem;display:block;color:#33ff00;}
|
|
126
|
+
.user-meta{color:#1a8c00;font-size:.75rem;margin-top:1px;}
|
|
127
|
+
.user-email{font-size:.6875rem;color:#116600;word-break:break-all;margin-top:1px;}
|
|
128
|
+
|
|
129
|
+
.settings-layout{
|
|
130
|
+
max-width:920px;margin:0 auto;padding:28px 20px;
|
|
131
|
+
display:flex;gap:28px;
|
|
132
|
+
}
|
|
133
|
+
.settings-sidebar{width:200px;flex-shrink:0;}
|
|
134
|
+
.settings-sidebar a{
|
|
135
|
+
display:block;padding:6px 10px;border-radius:6px;color:#1a8c00;
|
|
136
|
+
text-decoration:none;font-size:.8125rem;transition:color .15s;
|
|
137
|
+
}
|
|
138
|
+
.settings-sidebar a:hover{color:#33ff00;}
|
|
139
|
+
.settings-sidebar a.active{color:#33ff00;font-weight:600;}
|
|
140
|
+
.settings-main{flex:1;min-width:0;}
|
|
141
|
+
|
|
142
|
+
.s-card{
|
|
143
|
+
padding:18px 0;margin-bottom:14px;border-bottom:1px solid #0a3300;
|
|
144
|
+
}
|
|
145
|
+
.s-card:last-child{border-bottom:none;}
|
|
146
|
+
.s-card-header{display:flex;align-items:center;gap:14px;margin-bottom:14px;}
|
|
147
|
+
.s-icon{
|
|
148
|
+
width:42px;height:42px;border-radius:8px;
|
|
149
|
+
background:#0a3300;display:flex;align-items:center;justify-content:center;
|
|
150
|
+
font-size:1.125rem;font-weight:700;color:#116600;flex-shrink:0;
|
|
151
|
+
font-family:'Geist Pixel',monospace;
|
|
152
|
+
}
|
|
153
|
+
.s-title{
|
|
154
|
+
font-family:'Geist Pixel',monospace;
|
|
155
|
+
font-size:1.25rem;font-weight:600;color:#33ff00;
|
|
156
|
+
}
|
|
157
|
+
.s-subtitle{font-size:.75rem;color:#1a8c00;margin-top:2px;}
|
|
158
|
+
.section-heading{
|
|
159
|
+
font-size:.9375rem;font-weight:600;margin-bottom:10px;color:#33ff00;
|
|
160
|
+
display:flex;align-items:center;justify-content:space-between;
|
|
161
|
+
}
|
|
162
|
+
.perm-list{list-style:none;}
|
|
163
|
+
.perm-list li{padding:5px 0;font-size:.8125rem;display:flex;align-items:center;gap:6px;color:#1a8c00;}
|
|
164
|
+
.check{color:#33ff00;}
|
|
165
|
+
.org-row{
|
|
166
|
+
display:flex;align-items:center;gap:8px;padding:7px 0;
|
|
167
|
+
border-bottom:1px solid #0a3300;font-size:.8125rem;
|
|
168
|
+
}
|
|
169
|
+
.org-row:last-child{border-bottom:none;}
|
|
170
|
+
.org-icon{
|
|
171
|
+
width:22px;height:22px;border-radius:4px;background:#0a3300;
|
|
172
|
+
display:flex;align-items:center;justify-content:center;
|
|
173
|
+
font-size:.625rem;font-weight:700;color:#116600;flex-shrink:0;
|
|
174
|
+
font-family:'Geist Pixel',monospace;
|
|
175
|
+
}
|
|
176
|
+
.org-name{font-weight:600;color:#33ff00;}
|
|
177
|
+
.badge{font-size:.6875rem;padding:1px 7px;border-radius:999px;font-weight:500;}
|
|
178
|
+
.badge-granted{background:#0a3300;color:#33ff00;}
|
|
179
|
+
.badge-denied{background:#1a0a0a;color:#ff4444;}
|
|
180
|
+
.badge-requested{background:#0a3300;color:#1a8c00;}
|
|
181
|
+
.btn-revoke{
|
|
182
|
+
display:inline-block;padding:5px 14px;border-radius:6px;
|
|
183
|
+
border:1px solid #0a3300;background:transparent;color:#ff4444;
|
|
184
|
+
font-size:.75rem;font-weight:600;cursor:pointer;transition:border-color .15s;
|
|
185
|
+
}
|
|
186
|
+
.btn-revoke:hover{border-color:#ff4444;}
|
|
187
|
+
.info-text{color:#1a8c00;font-size:.75rem;line-height:1.5;margin-top:10px;}
|
|
188
|
+
.info-text a,.section-heading a{color:#1a8c00;text-decoration:none;transition:color .15s;}
|
|
189
|
+
.info-text a:hover,.section-heading a:hover{color:#33ff00;}
|
|
190
|
+
code{font-family:'Geist Mono','SF Mono',ui-monospace,monospace;font-size:.8125rem;color:#33ff00;word-break:break-all;}
|
|
191
|
+
.code-block{
|
|
192
|
+
background:#020;border:1px solid #0a3300;border-radius:6px;padding:10px 12px;
|
|
193
|
+
margin:8px 0 12px;overflow-x:auto;
|
|
194
|
+
}
|
|
195
|
+
.code-block code{white-space:pre;word-break:normal;display:block;line-height:1.5;}
|
|
196
|
+
.app-link{
|
|
197
|
+
display:flex;align-items:center;gap:12px;padding:12px;
|
|
198
|
+
border:1px solid #0a3300;border-radius:8px;background:#000;
|
|
199
|
+
text-decoration:none;color:inherit;margin-bottom:8px;transition:border-color .15s;
|
|
200
|
+
}
|
|
201
|
+
.app-link:hover{border-color:#33ff00;}
|
|
202
|
+
.app-link-name{font-weight:600;font-size:.875rem;color:#33ff00;}
|
|
203
|
+
.app-link-scopes{font-size:.6875rem;color:#1a8c00;margin-top:1px;}
|
|
204
|
+
.empty{color:#1a8c00;text-align:center;padding:28px 0;font-size:.875rem;}
|
|
205
|
+
|
|
206
|
+
.inspector-layout{max-width:960px;margin:0 auto;padding:28px 20px;}
|
|
207
|
+
.inspector-tabs{display:flex;gap:4px;margin-bottom:20px;}
|
|
208
|
+
.inspector-tabs a{
|
|
209
|
+
padding:7px 16px;border-radius:6px;text-decoration:none;
|
|
210
|
+
font-size:.8125rem;color:#1a8c00;border:1px solid transparent;
|
|
211
|
+
transition:color .15s,border-color .15s;
|
|
212
|
+
}
|
|
213
|
+
.inspector-tabs a:hover{color:#33ff00;}
|
|
214
|
+
.inspector-tabs a.active{color:#33ff00;font-weight:600;border-color:#0a3300;background:#0a3300;}
|
|
215
|
+
.inspector-section{margin-bottom:24px;}
|
|
216
|
+
.inspector-section h2{
|
|
217
|
+
font-family:'Geist Pixel',monospace;
|
|
218
|
+
font-size:1rem;font-weight:600;color:#33ff00;margin-bottom:10px;
|
|
219
|
+
}
|
|
220
|
+
.inspector-section h3{
|
|
221
|
+
font-family:'Geist Pixel',monospace;
|
|
222
|
+
font-size:.875rem;font-weight:600;color:#1a8c00;margin:16px 0 8px;
|
|
223
|
+
}
|
|
224
|
+
.inspector-table{width:100%;border-collapse:collapse;margin-bottom:12px;}
|
|
225
|
+
.inspector-table th,.inspector-table td{
|
|
226
|
+
text-align:left;padding:8px 12px;border-bottom:1px solid #0a3300;
|
|
227
|
+
font-size:.8125rem;
|
|
228
|
+
}
|
|
229
|
+
.inspector-table th{color:#1a8c00;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;}
|
|
230
|
+
.inspector-table td{color:#33ff00;}
|
|
231
|
+
.inspector-table tbody tr{transition:background .1s;}
|
|
232
|
+
.inspector-table tbody tr:hover{background:#0a3300;}
|
|
233
|
+
.inspector-empty{color:#1a8c00;text-align:center;padding:20px 0;font-size:.8125rem;}
|
|
234
|
+
|
|
235
|
+
.checkout-layout{
|
|
236
|
+
display:flex;min-height:calc(100vh - 42px);
|
|
237
|
+
}
|
|
238
|
+
.checkout-summary{
|
|
239
|
+
flex:1;background:#020;padding:48px 40px 48px 10%;
|
|
240
|
+
display:flex;flex-direction:column;justify-content:center;
|
|
241
|
+
border-right:1px solid #0a3300;
|
|
242
|
+
}
|
|
243
|
+
.checkout-form-side{
|
|
244
|
+
flex:1;background:#000;padding:48px 10% 48px 40px;
|
|
245
|
+
display:flex;flex-direction:column;justify-content:center;
|
|
246
|
+
}
|
|
247
|
+
.checkout-merchant{
|
|
248
|
+
display:flex;align-items:center;gap:10px;margin-bottom:6px;
|
|
249
|
+
}
|
|
250
|
+
.checkout-merchant-name{
|
|
251
|
+
font-family:'Geist Pixel',monospace;
|
|
252
|
+
font-size:.9375rem;font-weight:600;color:#33ff00;
|
|
253
|
+
}
|
|
254
|
+
.checkout-test-badge{
|
|
255
|
+
font-size:.625rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
|
|
256
|
+
background:#0a3300;color:#1a8c00;padding:2px 8px;border-radius:4px;
|
|
257
|
+
}
|
|
258
|
+
.checkout-total{
|
|
259
|
+
font-family:'Geist Pixel',monospace;
|
|
260
|
+
font-size:2rem;font-weight:700;color:#33ff00;margin:8px 0 28px;
|
|
261
|
+
}
|
|
262
|
+
.checkout-line-item{
|
|
263
|
+
display:flex;align-items:center;gap:14px;padding:14px 0;
|
|
264
|
+
border-bottom:1px solid #0a3300;
|
|
265
|
+
}
|
|
266
|
+
.checkout-line-item:first-child{border-top:1px solid #0a3300;}
|
|
267
|
+
.checkout-item-icon{
|
|
268
|
+
width:42px;height:42px;border-radius:6px;background:#0a3300;
|
|
269
|
+
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
|
270
|
+
font-family:'Geist Pixel',monospace;font-size:.875rem;font-weight:700;color:#116600;
|
|
271
|
+
}
|
|
272
|
+
.checkout-item-details{flex:1;min-width:0;}
|
|
273
|
+
.checkout-item-name{font-size:.875rem;font-weight:600;color:#33ff00;}
|
|
274
|
+
.checkout-item-qty{font-size:.75rem;color:#1a8c00;margin-top:2px;}
|
|
275
|
+
.checkout-item-price{
|
|
276
|
+
font-size:.875rem;font-weight:600;color:#33ff00;text-align:right;white-space:nowrap;
|
|
277
|
+
}
|
|
278
|
+
.checkout-item-unit{font-size:.6875rem;color:#1a8c00;text-align:right;margin-top:2px;}
|
|
279
|
+
.checkout-totals{margin-top:20px;}
|
|
280
|
+
.checkout-totals-row{
|
|
281
|
+
display:flex;justify-content:space-between;padding:6px 0;
|
|
282
|
+
font-size:.8125rem;color:#1a8c00;
|
|
283
|
+
}
|
|
284
|
+
.checkout-totals-row.total{
|
|
285
|
+
border-top:1px solid #0a3300;margin-top:8px;padding-top:14px;
|
|
286
|
+
font-size:.9375rem;font-weight:600;color:#33ff00;
|
|
287
|
+
}
|
|
288
|
+
.checkout-form-section{margin-bottom:24px;}
|
|
289
|
+
.checkout-form-label{
|
|
290
|
+
font-size:.8125rem;font-weight:600;color:#33ff00;margin-bottom:8px;display:block;
|
|
291
|
+
}
|
|
292
|
+
.checkout-input{
|
|
293
|
+
width:100%;padding:10px 12px;border:1px solid #0a3300;border-radius:6px;
|
|
294
|
+
background:#020;color:#33ff00;font:inherit;font-size:.875rem;
|
|
295
|
+
transition:border-color .15s;outline:none;
|
|
296
|
+
}
|
|
297
|
+
.checkout-input:focus{border-color:#33ff00;}
|
|
298
|
+
.checkout-input::placeholder{color:#116600;}
|
|
299
|
+
.checkout-card-box{
|
|
300
|
+
border:1px solid #0a3300;border-radius:6px;padding:14px;
|
|
301
|
+
background:#020;
|
|
302
|
+
}
|
|
303
|
+
.checkout-card-row{
|
|
304
|
+
display:flex;gap:12px;margin-top:10px;
|
|
305
|
+
}
|
|
306
|
+
.checkout-card-row .checkout-input{flex:1;}
|
|
307
|
+
.checkout-sim-note{
|
|
308
|
+
font-size:.6875rem;color:#1a8c00;margin-top:10px;text-align:center;
|
|
309
|
+
font-style:italic;
|
|
310
|
+
}
|
|
311
|
+
.checkout-pay-btn{
|
|
312
|
+
width:100%;padding:14px;border:none;border-radius:8px;
|
|
313
|
+
background:#33ff00;color:#000;font:inherit;font-size:.9375rem;font-weight:700;
|
|
314
|
+
cursor:pointer;transition:background .15s;
|
|
315
|
+
font-family:'Geist Pixel',monospace;
|
|
316
|
+
}
|
|
317
|
+
.checkout-pay-btn:hover{background:#44ff22;}
|
|
318
|
+
.checkout-cancel{
|
|
319
|
+
text-align:center;margin-top:14px;
|
|
320
|
+
}
|
|
321
|
+
.checkout-cancel a{
|
|
322
|
+
color:#1a8c00;text-decoration:none;font-size:.8125rem;
|
|
323
|
+
transition:color .15s;
|
|
324
|
+
}
|
|
325
|
+
.checkout-cancel a:hover{color:#33ff00;}
|
|
326
|
+
@media(max-width:768px){
|
|
327
|
+
.checkout-layout{flex-direction:column;}
|
|
328
|
+
.checkout-summary{padding:32px 20px;border-right:none;border-bottom:1px solid #0a3300;}
|
|
329
|
+
.checkout-form-side{padding:32px 20px;}
|
|
330
|
+
}
|
|
331
|
+
`;
|
|
332
|
+
var POWERED_BY = `<div class="powered-by">Powered by <a href="https://emulate.dev" target="_blank" rel="noopener">emulate</a></div>`;
|
|
333
|
+
function emuBar(service) {
|
|
334
|
+
const title = service ? `${escapeHtml(service)} Emulator` : "Emulator";
|
|
335
|
+
return `<div class="emu-bar">
|
|
336
|
+
<span class="emu-bar-title">${title}</span>
|
|
337
|
+
<nav class="emu-bar-links">
|
|
338
|
+
<a href="https://github.com/vercel-labs/emulate/issues" target="_blank" rel="noopener"><span class="full">Report Issue</span><span class="short">Report</span></a>
|
|
339
|
+
<a href="https://github.com/vercel-labs/emulate" target="_blank" rel="noopener"><span class="full">Source Code</span><span class="short">Source</span></a>
|
|
340
|
+
<a href="https://emulate.dev" target="_blank" rel="noopener"><span class="full">Learn More</span><span class="short">Learn</span></a>
|
|
341
|
+
</nav>
|
|
342
|
+
</div>`;
|
|
343
|
+
}
|
|
344
|
+
function head(title) {
|
|
345
|
+
return `<!DOCTYPE html>
|
|
346
|
+
<html lang="en">
|
|
347
|
+
<head>
|
|
348
|
+
<meta charset="utf-8"/>
|
|
349
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
350
|
+
<link rel="icon" href="/_emulate/favicon.ico"/>
|
|
351
|
+
<title>${escapeHtml(title)} | emulate</title>
|
|
352
|
+
<style>${CSS}</style>
|
|
353
|
+
</head>`;
|
|
354
|
+
}
|
|
355
|
+
function renderCardPage(title, subtitle, body, service) {
|
|
356
|
+
return `${head(title)}
|
|
357
|
+
<body>
|
|
358
|
+
${emuBar(service)}
|
|
359
|
+
<div class="content">
|
|
360
|
+
<div class="content-inner">
|
|
361
|
+
<div class="card-title">${escapeHtml(title)}</div>
|
|
362
|
+
<div class="card-subtitle">${subtitle}</div>
|
|
363
|
+
${body}
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
${POWERED_BY}
|
|
367
|
+
</body></html>`;
|
|
368
|
+
}
|
|
369
|
+
function renderErrorPage(title, message, service) {
|
|
370
|
+
return `${head(title)}
|
|
371
|
+
<body>
|
|
372
|
+
${emuBar(service)}
|
|
373
|
+
<div class="content">
|
|
374
|
+
<div class="content-inner error-card">
|
|
375
|
+
<div class="error-title">${escapeHtml(title)}</div>
|
|
376
|
+
<div class="error-msg">${escapeHtml(message)}</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
${POWERED_BY}
|
|
380
|
+
</body></html>`;
|
|
381
|
+
}
|
|
382
|
+
function renderUserButton(opts) {
|
|
383
|
+
const hiddens = Object.entries(opts.hiddenFields).map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}"/>`).join("");
|
|
384
|
+
const nameLine = opts.name ? `<div class="user-meta">${escapeHtml(opts.name)}</div>` : "";
|
|
385
|
+
const emailLine = opts.email ? `<div class="user-email">${escapeHtml(opts.email)}</div>` : "";
|
|
386
|
+
return `<form class="user-form" method="post" action="${escapeAttr(opts.formAction)}">
|
|
387
|
+
${hiddens}
|
|
388
|
+
<button type="submit" class="user-btn">
|
|
389
|
+
<span class="avatar">${escapeHtml(opts.letter)}</span>
|
|
390
|
+
<span class="user-text">
|
|
391
|
+
<span class="user-login">${escapeHtml(opts.login)}</span>
|
|
392
|
+
${nameLine}${emailLine}
|
|
393
|
+
</span>
|
|
394
|
+
</button>
|
|
395
|
+
</form>`;
|
|
396
|
+
}
|
|
397
|
+
function normalizeUri(uri) {
|
|
398
|
+
try {
|
|
399
|
+
const u = new URL(uri);
|
|
400
|
+
return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
|
|
401
|
+
} catch {
|
|
402
|
+
return uri.replace(/\/+$/, "").split("?")[0];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function matchesRedirectUri(incoming, registered) {
|
|
406
|
+
const normalized = normalizeUri(incoming);
|
|
407
|
+
return registered.some((r) => normalizeUri(r) === normalized);
|
|
408
|
+
}
|
|
409
|
+
function constantTimeSecretEqual(a, b) {
|
|
410
|
+
const bufA = Buffer.from(a, "utf-8");
|
|
411
|
+
const bufB = Buffer.from(b, "utf-8");
|
|
412
|
+
if (bufA.length !== bufB.length) return false;
|
|
413
|
+
return timingSafeEqual(bufA, bufB);
|
|
414
|
+
}
|
|
415
|
+
function bodyStr(v) {
|
|
416
|
+
if (typeof v === "string") return v;
|
|
417
|
+
if (Array.isArray(v) && typeof v[0] === "string") return v[0];
|
|
418
|
+
return "";
|
|
419
|
+
}
|
|
420
|
+
var SERVICE_LABEL = "X";
|
|
421
|
+
var AUTH_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
422
|
+
var ACCESS_TOKEN_TTL_SECONDS = 7200;
|
|
423
|
+
var X_SCOPES = [
|
|
424
|
+
"tweet.read",
|
|
425
|
+
"tweet.write",
|
|
426
|
+
"tweet.moderate.write",
|
|
427
|
+
"users.read",
|
|
428
|
+
"follows.read",
|
|
429
|
+
"follows.write",
|
|
430
|
+
"offline.access",
|
|
431
|
+
"space.read",
|
|
432
|
+
"mute.read",
|
|
433
|
+
"mute.write",
|
|
434
|
+
"like.read",
|
|
435
|
+
"like.write",
|
|
436
|
+
"list.read",
|
|
437
|
+
"list.write",
|
|
438
|
+
"block.read",
|
|
439
|
+
"block.write",
|
|
440
|
+
"bookmark.read",
|
|
441
|
+
"bookmark.write"
|
|
442
|
+
];
|
|
443
|
+
function parseClientCredentials(c, body) {
|
|
444
|
+
const basic = /^Basic\s+(.+)$/i.exec(c.req.header("Authorization") ?? "");
|
|
445
|
+
if (basic) {
|
|
446
|
+
try {
|
|
447
|
+
const decoded = Buffer.from(basic[1].trim(), "base64").toString("utf-8");
|
|
448
|
+
const sep = decoded.indexOf(":");
|
|
449
|
+
if (sep >= 0) {
|
|
450
|
+
return {
|
|
451
|
+
clientId: decodeURIComponent(decoded.slice(0, sep)),
|
|
452
|
+
clientSecret: decodeURIComponent(decoded.slice(sep + 1)),
|
|
453
|
+
fromBasic: true
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const clientId = typeof body.client_id === "string" ? body.client_id : "";
|
|
460
|
+
const clientSecret = typeof body.client_secret === "string" ? body.client_secret : null;
|
|
461
|
+
return { clientId, clientSecret, fromBasic: false };
|
|
462
|
+
}
|
|
463
|
+
function s256(verifier) {
|
|
464
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
465
|
+
}
|
|
466
|
+
async function parseTokenBody(c) {
|
|
467
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
468
|
+
const rawText = await c.req.text();
|
|
469
|
+
if (contentType.includes("application/json")) {
|
|
470
|
+
try {
|
|
471
|
+
return JSON.parse(rawText);
|
|
472
|
+
} catch {
|
|
473
|
+
return {};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return Object.fromEntries(new URLSearchParams(rawText));
|
|
477
|
+
}
|
|
478
|
+
function opaqueToken() {
|
|
479
|
+
return randomBytes(32).toString("base64url");
|
|
480
|
+
}
|
|
481
|
+
function oauthRoutes({ app, store, baseUrl }) {
|
|
482
|
+
const xs = getXStore(store);
|
|
483
|
+
function authenticateClient(creds, _body) {
|
|
484
|
+
if (!creds.clientId) {
|
|
485
|
+
return {
|
|
486
|
+
error: "invalid_request",
|
|
487
|
+
description: "A client_id is required. Public clients must include client_id in the request body.",
|
|
488
|
+
status: 400
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const client = xs.oauthClients.findOneBy("client_id", creds.clientId);
|
|
492
|
+
if (!client) {
|
|
493
|
+
return { error: "invalid_client", description: "Unknown client_id.", status: 401 };
|
|
494
|
+
}
|
|
495
|
+
if (client.client_type === "confidential") {
|
|
496
|
+
if (!creds.fromBasic) {
|
|
497
|
+
return {
|
|
498
|
+
error: "invalid_client",
|
|
499
|
+
description: "Confidential clients must authenticate with HTTP Basic.",
|
|
500
|
+
status: 401
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (!constantTimeSecretEqual(creds.clientSecret ?? "", client.client_secret ?? "")) {
|
|
504
|
+
return { error: "invalid_client", description: "Invalid client credentials.", status: 401 };
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
if (creds.fromBasic || creds.clientSecret != null) {
|
|
508
|
+
return {
|
|
509
|
+
error: "invalid_client",
|
|
510
|
+
description: "Public clients have no client_secret and must authenticate with PKCE only.",
|
|
511
|
+
status: 401
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { client };
|
|
516
|
+
}
|
|
517
|
+
app.get("/2/oauth2/authorize", (c) => {
|
|
518
|
+
const client_id = c.req.query("client_id") ?? "";
|
|
519
|
+
const redirect_uri = c.req.query("redirect_uri") ?? "";
|
|
520
|
+
const scope = c.req.query("scope") ?? "";
|
|
521
|
+
const state = c.req.query("state") ?? "";
|
|
522
|
+
const response_type = c.req.query("response_type") ?? "";
|
|
523
|
+
const code_challenge = c.req.query("code_challenge") ?? "";
|
|
524
|
+
const code_challenge_method = c.req.query("code_challenge_method") ?? "";
|
|
525
|
+
const client = xs.oauthClients.findOneBy("client_id", client_id);
|
|
526
|
+
if (!client) {
|
|
527
|
+
return c.html(
|
|
528
|
+
renderErrorPage(
|
|
529
|
+
"Application not found",
|
|
530
|
+
`The client_id '${escapeHtml(client_id)}' is not registered.`,
|
|
531
|
+
SERVICE_LABEL
|
|
532
|
+
),
|
|
533
|
+
400
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (redirect_uri && !matchesRedirectUri(redirect_uri, client.redirect_uris)) {
|
|
537
|
+
return c.html(
|
|
538
|
+
renderErrorPage(
|
|
539
|
+
"Redirect URI mismatch",
|
|
540
|
+
"The redirect_uri is not registered for this application.",
|
|
541
|
+
SERVICE_LABEL
|
|
542
|
+
),
|
|
543
|
+
400
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (response_type && response_type !== "code") {
|
|
547
|
+
return c.html(
|
|
548
|
+
renderErrorPage("Unsupported response_type", "Only response_type=code is supported.", SERVICE_LABEL),
|
|
549
|
+
400
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (!code_challenge) {
|
|
553
|
+
return c.html(
|
|
554
|
+
renderErrorPage(
|
|
555
|
+
"PKCE required",
|
|
556
|
+
"A code_challenge is required for the authorization code flow.",
|
|
557
|
+
SERVICE_LABEL
|
|
558
|
+
),
|
|
559
|
+
400
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
if (code_challenge_method.toUpperCase() !== "S256") {
|
|
563
|
+
return c.html(
|
|
564
|
+
renderErrorPage(
|
|
565
|
+
"Unsupported PKCE method",
|
|
566
|
+
"code_challenge_method must be S256 for the X authorization code flow.",
|
|
567
|
+
SERVICE_LABEL
|
|
568
|
+
),
|
|
569
|
+
400
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const requestedScopes = scope.split(/[\s+]+/).filter(Boolean);
|
|
573
|
+
const unknownScope = requestedScopes.find((s) => !X_SCOPES.includes(s));
|
|
574
|
+
if (unknownScope) {
|
|
575
|
+
return c.html(
|
|
576
|
+
renderErrorPage("Invalid scope", `The scope '${escapeHtml(unknownScope)}' is not supported.`, SERVICE_LABEL),
|
|
577
|
+
400
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const users = [...xs.users.all()].sort((a, b) => a.username.localeCompare(b.username));
|
|
581
|
+
const subtitleText = `Authorize <strong>${escapeHtml(client.name)}</strong> to access your X account.`;
|
|
582
|
+
const userButtons = users.map(
|
|
583
|
+
(u) => renderUserButton({
|
|
584
|
+
letter: (u.name[0] ?? u.username[0] ?? "?").toUpperCase(),
|
|
585
|
+
login: `@${u.username}`,
|
|
586
|
+
name: u.name,
|
|
587
|
+
formAction: `${baseUrl}/2/oauth2/authorize/consent`,
|
|
588
|
+
hiddenFields: {
|
|
589
|
+
user_id: u.user_id,
|
|
590
|
+
redirect_uri,
|
|
591
|
+
scope,
|
|
592
|
+
state,
|
|
593
|
+
client_id,
|
|
594
|
+
code_challenge,
|
|
595
|
+
code_challenge_method
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
).join("\n");
|
|
599
|
+
const body = users.length === 0 ? '<p class="empty">No users in the emulator store.</p>' : userButtons;
|
|
600
|
+
return c.html(renderCardPage("Sign in to X", subtitleText, body, SERVICE_LABEL));
|
|
601
|
+
});
|
|
602
|
+
app.post("/2/oauth2/authorize/consent", async (c) => {
|
|
603
|
+
const form = await c.req.parseBody();
|
|
604
|
+
const user_id = bodyStr(form.user_id);
|
|
605
|
+
const redirect_uri = bodyStr(form.redirect_uri);
|
|
606
|
+
const scope = bodyStr(form.scope);
|
|
607
|
+
const state = bodyStr(form.state);
|
|
608
|
+
const client_id = bodyStr(form.client_id);
|
|
609
|
+
const code_challenge = bodyStr(form.code_challenge);
|
|
610
|
+
const code_challenge_method = bodyStr(form.code_challenge_method);
|
|
611
|
+
const user = xs.users.findOneBy("user_id", user_id);
|
|
612
|
+
if (!user) {
|
|
613
|
+
return c.html(renderErrorPage("User not found", "The selected user no longer exists.", SERVICE_LABEL), 400);
|
|
614
|
+
}
|
|
615
|
+
const code = randomBytes(24).toString("base64url");
|
|
616
|
+
xs.authCodes.insert({
|
|
617
|
+
code,
|
|
618
|
+
client_id,
|
|
619
|
+
redirect_uri,
|
|
620
|
+
code_challenge,
|
|
621
|
+
code_challenge_method: code_challenge_method || "S256",
|
|
622
|
+
scopes: scope.split(/[\s+]+/).filter(Boolean),
|
|
623
|
+
user_id,
|
|
624
|
+
expires: Date.now() + AUTH_CODE_TTL_MS
|
|
625
|
+
});
|
|
626
|
+
debug("x.oauth", `[authorize] minted code for user ${user_id} client ${client_id}`);
|
|
627
|
+
const url = new URL(redirect_uri);
|
|
628
|
+
url.searchParams.set("code", code);
|
|
629
|
+
if (state) url.searchParams.set("state", state);
|
|
630
|
+
return c.redirect(url.toString(), 302);
|
|
631
|
+
});
|
|
632
|
+
app.post("/2/oauth2/token", async (c) => {
|
|
633
|
+
const body = await parseTokenBody(c);
|
|
634
|
+
const grant_type = typeof body.grant_type === "string" ? body.grant_type : "";
|
|
635
|
+
const creds = parseClientCredentials(c, body);
|
|
636
|
+
if (grant_type === "client_credentials") {
|
|
637
|
+
const auth = authenticateClient(creds, body);
|
|
638
|
+
if ("error" in auth) {
|
|
639
|
+
return c.json({ error: auth.error, error_description: auth.description }, auth.status);
|
|
640
|
+
}
|
|
641
|
+
if (auth.client.client_type !== "confidential") {
|
|
642
|
+
return c.json(
|
|
643
|
+
{
|
|
644
|
+
error: "unauthorized_client",
|
|
645
|
+
error_description: "Only confidential clients may use the client_credentials grant."
|
|
646
|
+
},
|
|
647
|
+
400
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const token = opaqueToken();
|
|
651
|
+
const expires = Date.now() + ACCESS_TOKEN_TTL_SECONDS * 1e3;
|
|
652
|
+
xs.accessTokens.insert({
|
|
653
|
+
token,
|
|
654
|
+
client_id: auth.client.client_id,
|
|
655
|
+
user_id: null,
|
|
656
|
+
scopes: [],
|
|
657
|
+
app_only: true,
|
|
658
|
+
expires
|
|
659
|
+
});
|
|
660
|
+
return c.json({
|
|
661
|
+
token_type: "bearer",
|
|
662
|
+
access_token: token,
|
|
663
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (grant_type === "authorization_code") {
|
|
667
|
+
const auth = authenticateClient(creds, body);
|
|
668
|
+
if ("error" in auth) {
|
|
669
|
+
return c.json({ error: auth.error, error_description: auth.description }, auth.status);
|
|
670
|
+
}
|
|
671
|
+
const client = auth.client;
|
|
672
|
+
const code = typeof body.code === "string" ? body.code : "";
|
|
673
|
+
const redirect_uri = typeof body.redirect_uri === "string" ? body.redirect_uri : "";
|
|
674
|
+
const code_verifier = typeof body.code_verifier === "string" ? body.code_verifier : "";
|
|
675
|
+
const codeRow = xs.authCodes.findOneBy("code", code);
|
|
676
|
+
if (!codeRow || codeRow.expires < Date.now()) {
|
|
677
|
+
if (codeRow) xs.authCodes.delete(codeRow.id);
|
|
678
|
+
return c.json(
|
|
679
|
+
{ error: "invalid_grant", error_description: "The authorization code is invalid or expired." },
|
|
680
|
+
400
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (codeRow.client_id !== client.client_id) {
|
|
684
|
+
return c.json(
|
|
685
|
+
{ error: "invalid_grant", error_description: "The authorization code was issued to another client." },
|
|
686
|
+
400
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
if (codeRow.redirect_uri !== redirect_uri) {
|
|
690
|
+
return c.json(
|
|
691
|
+
{ error: "invalid_grant", error_description: "The redirect_uri does not match the authorization request." },
|
|
692
|
+
400
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (!code_verifier) {
|
|
696
|
+
return c.json({ error: "invalid_request", error_description: "A code_verifier is required (PKCE)." }, 400);
|
|
697
|
+
}
|
|
698
|
+
if (s256(code_verifier) !== codeRow.code_challenge) {
|
|
699
|
+
return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400);
|
|
700
|
+
}
|
|
701
|
+
xs.authCodes.delete(codeRow.id);
|
|
702
|
+
const scopes = codeRow.scopes;
|
|
703
|
+
const token = opaqueToken();
|
|
704
|
+
const expires = Date.now() + ACCESS_TOKEN_TTL_SECONDS * 1e3;
|
|
705
|
+
xs.accessTokens.insert({
|
|
706
|
+
token,
|
|
707
|
+
client_id: client.client_id,
|
|
708
|
+
user_id: codeRow.user_id,
|
|
709
|
+
scopes,
|
|
710
|
+
app_only: false,
|
|
711
|
+
expires
|
|
712
|
+
});
|
|
713
|
+
const response = {
|
|
714
|
+
token_type: "bearer",
|
|
715
|
+
access_token: token,
|
|
716
|
+
scope: scopes.join(" "),
|
|
717
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS
|
|
718
|
+
};
|
|
719
|
+
if (scopes.includes("offline.access")) {
|
|
720
|
+
const refreshToken = opaqueToken();
|
|
721
|
+
xs.refreshTokens.insert({
|
|
722
|
+
token: refreshToken,
|
|
723
|
+
client_id: client.client_id,
|
|
724
|
+
user_id: codeRow.user_id,
|
|
725
|
+
scopes
|
|
726
|
+
});
|
|
727
|
+
response.refresh_token = refreshToken;
|
|
728
|
+
}
|
|
729
|
+
debug("x.oauth", `[token] authorization_code \u2192 user ${codeRow.user_id} scopes ${scopes.join(",")}`);
|
|
730
|
+
return c.json(response);
|
|
731
|
+
}
|
|
732
|
+
if (grant_type === "refresh_token") {
|
|
733
|
+
const auth = authenticateClient(creds, body);
|
|
734
|
+
if ("error" in auth) {
|
|
735
|
+
return c.json({ error: auth.error, error_description: auth.description }, auth.status);
|
|
736
|
+
}
|
|
737
|
+
const client = auth.client;
|
|
738
|
+
const refresh_token = typeof body.refresh_token === "string" ? body.refresh_token : "";
|
|
739
|
+
const row = xs.refreshTokens.findOneBy("token", refresh_token);
|
|
740
|
+
if (!row || row.client_id !== client.client_id) {
|
|
741
|
+
return c.json({ error: "invalid_grant", error_description: "The refresh token is invalid." }, 400);
|
|
742
|
+
}
|
|
743
|
+
if (!row.scopes.includes("offline.access")) {
|
|
744
|
+
return c.json(
|
|
745
|
+
{ error: "invalid_grant", error_description: "The refresh token does not have offline.access." },
|
|
746
|
+
400
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const token = opaqueToken();
|
|
750
|
+
const expires = Date.now() + ACCESS_TOKEN_TTL_SECONDS * 1e3;
|
|
751
|
+
xs.accessTokens.insert({
|
|
752
|
+
token,
|
|
753
|
+
client_id: client.client_id,
|
|
754
|
+
user_id: row.user_id,
|
|
755
|
+
scopes: row.scopes,
|
|
756
|
+
app_only: false,
|
|
757
|
+
expires
|
|
758
|
+
});
|
|
759
|
+
const newRefresh = opaqueToken();
|
|
760
|
+
xs.refreshTokens.update(row.id, { token: newRefresh });
|
|
761
|
+
return c.json({
|
|
762
|
+
token_type: "bearer",
|
|
763
|
+
access_token: token,
|
|
764
|
+
scope: row.scopes.join(" "),
|
|
765
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
766
|
+
refresh_token: newRefresh
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return c.json(
|
|
770
|
+
{
|
|
771
|
+
error: "unsupported_grant_type",
|
|
772
|
+
error_description: "grant_type must be authorization_code, refresh_token, or client_credentials."
|
|
773
|
+
},
|
|
774
|
+
400
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
app.post("/2/oauth2/revoke", async (c) => {
|
|
778
|
+
const body = await parseTokenBody(c);
|
|
779
|
+
const creds = parseClientCredentials(c, body);
|
|
780
|
+
const auth = authenticateClient(creds, body);
|
|
781
|
+
if ("error" in auth) {
|
|
782
|
+
return c.json({ error: auth.error, error_description: auth.description }, auth.status);
|
|
783
|
+
}
|
|
784
|
+
const token = typeof body.token === "string" ? body.token : "";
|
|
785
|
+
if (token) {
|
|
786
|
+
const access = xs.accessTokens.findOneBy("token", token);
|
|
787
|
+
if (access && access.client_id === auth.client.client_id) {
|
|
788
|
+
xs.accessTokens.delete(access.id);
|
|
789
|
+
}
|
|
790
|
+
const refresh = xs.refreshTokens.findOneBy("token", token);
|
|
791
|
+
if (refresh && refresh.client_id === auth.client.client_id) {
|
|
792
|
+
xs.refreshTokens.delete(refresh.id);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return c.json({ revoked: true });
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function resolveToken(store, c) {
|
|
799
|
+
const m = /^Bearer\s+(.+)$/i.exec(c.req.header("Authorization") ?? "");
|
|
800
|
+
if (!m) return null;
|
|
801
|
+
return lookupAccessToken(store, m[1].trim()) ?? null;
|
|
802
|
+
}
|
|
803
|
+
function unauthorized(c) {
|
|
804
|
+
return c.json(
|
|
805
|
+
{
|
|
806
|
+
title: "Unauthorized",
|
|
807
|
+
type: "about:blank",
|
|
808
|
+
status: 401,
|
|
809
|
+
detail: "Unauthorized"
|
|
810
|
+
},
|
|
811
|
+
401
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
function forbidden(c, detail) {
|
|
815
|
+
return c.json(
|
|
816
|
+
{
|
|
817
|
+
title: "Forbidden",
|
|
818
|
+
type: "https://api.twitter.com/2/problems/oauth2-insufficient-scope",
|
|
819
|
+
status: 403,
|
|
820
|
+
detail
|
|
821
|
+
},
|
|
822
|
+
403
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
function hasUserScope(token, ...required) {
|
|
826
|
+
if (token.app_only) return false;
|
|
827
|
+
return required.every((s) => token.scopes.includes(s));
|
|
828
|
+
}
|
|
829
|
+
function formatUser(u) {
|
|
830
|
+
return {
|
|
831
|
+
id: u.user_id,
|
|
832
|
+
name: u.name,
|
|
833
|
+
username: u.username,
|
|
834
|
+
created_at: u.created_at_x,
|
|
835
|
+
description: u.description,
|
|
836
|
+
location: u.location,
|
|
837
|
+
url: u.url,
|
|
838
|
+
protected: u.protected,
|
|
839
|
+
verified: u.verified,
|
|
840
|
+
profile_image_url: u.profile_image_url,
|
|
841
|
+
public_metrics: {
|
|
842
|
+
followers_count: u.followers_count,
|
|
843
|
+
following_count: u.following_count,
|
|
844
|
+
tweet_count: u.tweet_count,
|
|
845
|
+
listed_count: u.listed_count
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function notFound(c, detail) {
|
|
850
|
+
return c.json(
|
|
851
|
+
{
|
|
852
|
+
errors: [
|
|
853
|
+
{
|
|
854
|
+
title: "Not Found Error",
|
|
855
|
+
type: "https://api.twitter.com/2/problems/resource-not-found",
|
|
856
|
+
detail
|
|
857
|
+
}
|
|
858
|
+
]
|
|
859
|
+
},
|
|
860
|
+
404
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
function usersRoutes({ app, store }) {
|
|
864
|
+
const xs = getXStore(store);
|
|
865
|
+
app.get("/2/users/me", (c) => {
|
|
866
|
+
const token = resolveToken(store, c);
|
|
867
|
+
if (!token) return unauthorized(c);
|
|
868
|
+
if (token.app_only) {
|
|
869
|
+
return forbidden(c, "This endpoint requires a user-context OAuth 2.0 token; an app-only token has no user.");
|
|
870
|
+
}
|
|
871
|
+
if (!hasUserScope(token, "users.read")) {
|
|
872
|
+
return forbidden(c, "Your token is missing the users.read scope required by this endpoint.");
|
|
873
|
+
}
|
|
874
|
+
const user = token.user_id ? xs.users.findOneBy("user_id", token.user_id) : void 0;
|
|
875
|
+
if (!user) return unauthorized(c);
|
|
876
|
+
return c.json({ data: formatUser(user) });
|
|
877
|
+
});
|
|
878
|
+
app.get("/2/users/:id", (c) => {
|
|
879
|
+
const token = resolveToken(store, c);
|
|
880
|
+
if (!token) return unauthorized(c);
|
|
881
|
+
if (!token.app_only && !hasUserScope(token, "users.read")) {
|
|
882
|
+
return forbidden(c, "Your token is missing the users.read scope required by this endpoint.");
|
|
883
|
+
}
|
|
884
|
+
const user = xs.users.findOneBy("user_id", c.req.param("id"));
|
|
885
|
+
if (!user) return notFound(c, `Could not find user with id: [${c.req.param("id")}].`);
|
|
886
|
+
return c.json({ data: formatUser(user) });
|
|
887
|
+
});
|
|
888
|
+
app.get("/2/users/by/username/:username", (c) => {
|
|
889
|
+
const token = resolveToken(store, c);
|
|
890
|
+
if (!token) return unauthorized(c);
|
|
891
|
+
if (!token.app_only && !hasUserScope(token, "users.read")) {
|
|
892
|
+
return forbidden(c, "Your token is missing the users.read scope required by this endpoint.");
|
|
893
|
+
}
|
|
894
|
+
const username = c.req.param("username").toLowerCase();
|
|
895
|
+
const user = xs.users.findOneBy("username", username);
|
|
896
|
+
if (!user) return notFound(c, `Could not find user with username: [${c.req.param("username")}].`);
|
|
897
|
+
return c.json({ data: formatUser(user) });
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
function formatTweet(t) {
|
|
901
|
+
return {
|
|
902
|
+
id: t.tweet_id,
|
|
903
|
+
text: t.text,
|
|
904
|
+
author_id: t.author_id,
|
|
905
|
+
created_at: t.created_at_x,
|
|
906
|
+
conversation_id: t.conversation_id,
|
|
907
|
+
lang: t.lang,
|
|
908
|
+
possibly_sensitive: t.possibly_sensitive,
|
|
909
|
+
in_reply_to_user_id: t.in_reply_to_user_id,
|
|
910
|
+
edit_history_tweet_ids: [t.tweet_id],
|
|
911
|
+
public_metrics: {
|
|
912
|
+
retweet_count: t.retweet_count,
|
|
913
|
+
reply_count: t.reply_count,
|
|
914
|
+
like_count: t.like_count,
|
|
915
|
+
quote_count: t.quote_count,
|
|
916
|
+
impression_count: t.impression_count
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function notFound2(c, detail) {
|
|
921
|
+
return c.json(
|
|
922
|
+
{
|
|
923
|
+
errors: [
|
|
924
|
+
{
|
|
925
|
+
title: "Not Found Error",
|
|
926
|
+
type: "https://api.twitter.com/2/problems/resource-not-found",
|
|
927
|
+
detail
|
|
928
|
+
}
|
|
929
|
+
]
|
|
930
|
+
},
|
|
931
|
+
404
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
function tweetsRoutes({ app, store }) {
|
|
935
|
+
const xs = getXStore(store);
|
|
936
|
+
app.get("/2/tweets/:id", (c) => {
|
|
937
|
+
const token = resolveToken(store, c);
|
|
938
|
+
if (!token) return unauthorized(c);
|
|
939
|
+
if (!token.app_only && !hasUserScope(token, "tweet.read")) {
|
|
940
|
+
return forbidden(c, "Your token is missing the tweet.read scope required by this endpoint.");
|
|
941
|
+
}
|
|
942
|
+
const tweet = xs.tweets.findOneBy("tweet_id", c.req.param("id"));
|
|
943
|
+
if (!tweet) return notFound2(c, `Could not find tweet with id: [${c.req.param("id")}].`);
|
|
944
|
+
return c.json({ data: formatTweet(tweet) });
|
|
945
|
+
});
|
|
946
|
+
app.get("/2/tweets", (c) => {
|
|
947
|
+
const token = resolveToken(store, c);
|
|
948
|
+
if (!token) return unauthorized(c);
|
|
949
|
+
if (!token.app_only && !hasUserScope(token, "tweet.read")) {
|
|
950
|
+
return forbidden(c, "Your token is missing the tweet.read scope required by this endpoint.");
|
|
951
|
+
}
|
|
952
|
+
const idsParam = c.req.query("ids") ?? "";
|
|
953
|
+
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
|
|
954
|
+
if (ids.length === 0) {
|
|
955
|
+
return c.json(
|
|
956
|
+
{
|
|
957
|
+
errors: [
|
|
958
|
+
{
|
|
959
|
+
parameters: { ids: [] },
|
|
960
|
+
message: "The `ids` query parameter is required and must be a comma-separated list of Tweet IDs."
|
|
961
|
+
}
|
|
962
|
+
],
|
|
963
|
+
title: "Invalid Request",
|
|
964
|
+
detail: "One or more parameters to your request was invalid.",
|
|
965
|
+
type: "https://api.twitter.com/2/problems/invalid-request"
|
|
966
|
+
},
|
|
967
|
+
400
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
const data = [];
|
|
971
|
+
const errors = [];
|
|
972
|
+
for (const id of ids) {
|
|
973
|
+
const tweet = xs.tweets.findOneBy("tweet_id", id);
|
|
974
|
+
if (tweet) {
|
|
975
|
+
data.push(formatTweet(tweet));
|
|
976
|
+
} else {
|
|
977
|
+
errors.push({
|
|
978
|
+
value: id,
|
|
979
|
+
detail: `Could not find tweet with ids: [${id}].`,
|
|
980
|
+
title: "Not Found Error",
|
|
981
|
+
resource_type: "tweet",
|
|
982
|
+
parameter: "ids",
|
|
983
|
+
resource_id: id,
|
|
984
|
+
type: "https://api.twitter.com/2/problems/resource-not-found"
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
const out = { data };
|
|
989
|
+
if (errors.length > 0) out.errors = errors;
|
|
990
|
+
return c.json(out);
|
|
991
|
+
});
|
|
992
|
+
app.get("/2/users/:id/tweets", (c) => {
|
|
993
|
+
const token = resolveToken(store, c);
|
|
994
|
+
if (!token) return unauthorized(c);
|
|
995
|
+
if (!token.app_only && !hasUserScope(token, "tweet.read")) {
|
|
996
|
+
return forbidden(c, "Your token is missing the tweet.read scope required by this endpoint.");
|
|
997
|
+
}
|
|
998
|
+
const authorId = c.req.param("id");
|
|
999
|
+
const author = xs.users.findOneBy("user_id", authorId);
|
|
1000
|
+
if (!author) return notFound2(c, `Could not find user with id: [${authorId}].`);
|
|
1001
|
+
const tweets = xs.tweets.findBy("author_id", authorId).sort((a, b) => b.created_at_x.localeCompare(a.created_at_x));
|
|
1002
|
+
return c.json({
|
|
1003
|
+
data: tweets.map(formatTweet),
|
|
1004
|
+
meta: {
|
|
1005
|
+
result_count: tweets.length,
|
|
1006
|
+
newest_id: tweets[0]?.tweet_id,
|
|
1007
|
+
oldest_id: tweets[tweets.length - 1]?.tweet_id
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
app.post("/2/tweets", async (c) => {
|
|
1012
|
+
const token = resolveToken(store, c);
|
|
1013
|
+
if (!token) return unauthorized(c);
|
|
1014
|
+
if (token.app_only) {
|
|
1015
|
+
return forbidden(c, "Creating a Tweet requires a user-context OAuth 2.0 token; an app-only token cannot post.");
|
|
1016
|
+
}
|
|
1017
|
+
if (!hasUserScope(token, "tweet.write")) {
|
|
1018
|
+
return forbidden(c, "Your token is missing the tweet.write scope required to create a Tweet.");
|
|
1019
|
+
}
|
|
1020
|
+
const author = token.user_id ? xs.users.findOneBy("user_id", token.user_id) : void 0;
|
|
1021
|
+
if (!author) return unauthorized(c);
|
|
1022
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1023
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
1024
|
+
if (!text.trim()) {
|
|
1025
|
+
return c.json(
|
|
1026
|
+
{
|
|
1027
|
+
errors: [{ message: "text or media is required", parameters: {} }],
|
|
1028
|
+
title: "Invalid Request",
|
|
1029
|
+
detail: "One or more parameters to your request was invalid.",
|
|
1030
|
+
type: "https://api.twitter.com/2/problems/invalid-request"
|
|
1031
|
+
},
|
|
1032
|
+
400
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
const reply = body.reply;
|
|
1036
|
+
const inReplyToTweetId = reply && typeof reply.in_reply_to_tweet_id === "string" ? reply.in_reply_to_tweet_id : null;
|
|
1037
|
+
const parent = inReplyToTweetId ? xs.tweets.findOneBy("tweet_id", inReplyToTweetId) : void 0;
|
|
1038
|
+
const tweetId = xNumericId();
|
|
1039
|
+
const tweet = xs.tweets.insert({
|
|
1040
|
+
tweet_id: tweetId,
|
|
1041
|
+
author_id: author.user_id,
|
|
1042
|
+
text,
|
|
1043
|
+
reply_count: 0,
|
|
1044
|
+
retweet_count: 0,
|
|
1045
|
+
like_count: 0,
|
|
1046
|
+
quote_count: 0,
|
|
1047
|
+
impression_count: 0,
|
|
1048
|
+
in_reply_to_user_id: parent ? parent.author_id : null,
|
|
1049
|
+
conversation_id: parent ? parent.conversation_id : tweetId,
|
|
1050
|
+
lang: "en",
|
|
1051
|
+
possibly_sensitive: false,
|
|
1052
|
+
created_at_x: (/* @__PURE__ */ new Date()).toISOString()
|
|
1053
|
+
});
|
|
1054
|
+
xs.users.update(author.id, { tweet_count: author.tweet_count + 1 });
|
|
1055
|
+
if (parent) xs.tweets.update(parent.id, { reply_count: parent.reply_count + 1 });
|
|
1056
|
+
return c.json({ data: { id: tweet.tweet_id, text: tweet.text } }, 201);
|
|
1057
|
+
});
|
|
1058
|
+
app.delete("/2/tweets/:id", (c) => {
|
|
1059
|
+
const token = resolveToken(store, c);
|
|
1060
|
+
if (!token) return unauthorized(c);
|
|
1061
|
+
if (token.app_only) {
|
|
1062
|
+
return forbidden(c, "Deleting a Tweet requires a user-context OAuth 2.0 token; an app-only token cannot delete.");
|
|
1063
|
+
}
|
|
1064
|
+
if (!hasUserScope(token, "tweet.write")) {
|
|
1065
|
+
return forbidden(c, "Your token is missing the tweet.write scope required to delete a Tweet.");
|
|
1066
|
+
}
|
|
1067
|
+
const tweet = xs.tweets.findOneBy("tweet_id", c.req.param("id"));
|
|
1068
|
+
if (!tweet) return notFound2(c, `Could not find tweet with id: [${c.req.param("id")}].`);
|
|
1069
|
+
if (token.user_id && tweet.author_id !== token.user_id) {
|
|
1070
|
+
return forbidden(c, "You may only delete your own Tweets.");
|
|
1071
|
+
}
|
|
1072
|
+
xs.tweets.delete(tweet.id);
|
|
1073
|
+
return c.json({ data: { deleted: true } });
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
function openapiRoutes({ app, baseUrl }) {
|
|
1077
|
+
const handler = (c) => c.json(buildSpec(baseUrl));
|
|
1078
|
+
app.get("/2/openapi.json", handler);
|
|
1079
|
+
app.get("/openapi.json", handler);
|
|
1080
|
+
}
|
|
1081
|
+
var ok = (description) => ({
|
|
1082
|
+
description,
|
|
1083
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
1084
|
+
});
|
|
1085
|
+
function buildSpec(baseUrl) {
|
|
1086
|
+
const userScopes = Object.fromEntries(X_SCOPES.map((s) => [s, `Scope: ${s}`]));
|
|
1087
|
+
return {
|
|
1088
|
+
openapi: "3.0.3",
|
|
1089
|
+
info: {
|
|
1090
|
+
title: "X API v2 (Emulated)",
|
|
1091
|
+
version: "2.0.0",
|
|
1092
|
+
description: "Emulated subset of the X (formerly Twitter) API v2. Supports app-only Bearer tokens (client_credentials), the OAuth 2.0 Authorization Code flow with PKCE (user context), and a documented-partial legacy OAuth 1.0a user context."
|
|
1093
|
+
},
|
|
1094
|
+
servers: [{ url: baseUrl }],
|
|
1095
|
+
components: {
|
|
1096
|
+
securitySchemes: {
|
|
1097
|
+
// App-only Bearer token, minted via POST /2/oauth2/token grant_type=client_credentials.
|
|
1098
|
+
BearerToken: {
|
|
1099
|
+
type: "http",
|
|
1100
|
+
scheme: "bearer",
|
|
1101
|
+
description: "App-only Bearer token (OAuth 2.0 client_credentials). Minted with client_secret_basic (HTTP Basic) only; client_secret_post is not supported. Read-only public endpoints."
|
|
1102
|
+
},
|
|
1103
|
+
// OAuth 2.0 Authorization Code with PKCE (S256). User context.
|
|
1104
|
+
OAuth2UserToken: {
|
|
1105
|
+
type: "oauth2",
|
|
1106
|
+
description: "OAuth 2.0 Authorization Code with PKCE (S256). User-context access and writes. Confidential clients authenticate with client_secret_basic (HTTP Basic) only; client_secret_post is not supported.",
|
|
1107
|
+
flows: {
|
|
1108
|
+
authorizationCode: {
|
|
1109
|
+
authorizationUrl: `${baseUrl}/2/oauth2/authorize`,
|
|
1110
|
+
tokenUrl: `${baseUrl}/2/oauth2/token`,
|
|
1111
|
+
scopes: userScopes
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
// Legacy OAuth 1.0a user context — declared faithfully but NOT implemented.
|
|
1116
|
+
UserToken: {
|
|
1117
|
+
type: "http",
|
|
1118
|
+
scheme: "OAuth",
|
|
1119
|
+
description: "Legacy OAuth 1.0a user context. Declared for fidelity but emulated as a partial/unsupported surface; the emulator does not validate OAuth 1.0a signatures."
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
security: [{ BearerToken: [] }, { OAuth2UserToken: [...X_SCOPES] }],
|
|
1124
|
+
paths: {
|
|
1125
|
+
"/2/oauth2/token": {
|
|
1126
|
+
post: {
|
|
1127
|
+
operationId: "oauth2Token",
|
|
1128
|
+
summary: "Token endpoint (authorization_code, refresh_token, client_credentials)",
|
|
1129
|
+
responses: { "200": ok("Token response."), "400": ok("OAuth error."), "401": ok("invalid_client.") }
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
"/2/oauth2/revoke": {
|
|
1133
|
+
post: { operationId: "oauth2Revoke", summary: "Revoke a token", responses: { "200": ok("Revoked.") } }
|
|
1134
|
+
},
|
|
1135
|
+
"/2/users/me": {
|
|
1136
|
+
get: {
|
|
1137
|
+
operationId: "findMyUser",
|
|
1138
|
+
summary: "Get the authenticated user",
|
|
1139
|
+
security: [{ OAuth2UserToken: ["users.read"] }],
|
|
1140
|
+
responses: { "200": ok("User object."), "401": ok("Unauthorized."), "403": ok("Insufficient scope.") }
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
"/2/users/{id}": {
|
|
1144
|
+
get: {
|
|
1145
|
+
operationId: "findUserById",
|
|
1146
|
+
summary: "Get a user by id",
|
|
1147
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1148
|
+
security: [{ BearerToken: [] }, { OAuth2UserToken: ["users.read"] }],
|
|
1149
|
+
responses: { "200": ok("User object."), "404": ok("Not found.") }
|
|
1150
|
+
}
|
|
1151
|
+
},
|
|
1152
|
+
"/2/users/by/username/{username}": {
|
|
1153
|
+
get: {
|
|
1154
|
+
operationId: "findUserByUsername",
|
|
1155
|
+
summary: "Get a user by username",
|
|
1156
|
+
parameters: [{ name: "username", in: "path", required: true, schema: { type: "string" } }],
|
|
1157
|
+
security: [{ BearerToken: [] }, { OAuth2UserToken: ["users.read"] }],
|
|
1158
|
+
responses: { "200": ok("User object."), "404": ok("Not found.") }
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
"/2/users/{id}/tweets": {
|
|
1162
|
+
get: {
|
|
1163
|
+
operationId: "usersIdTweets",
|
|
1164
|
+
summary: "Get a user's Tweets timeline",
|
|
1165
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1166
|
+
security: [{ BearerToken: [] }, { OAuth2UserToken: ["tweet.read", "users.read"] }],
|
|
1167
|
+
responses: { "200": ok("Tweet list."), "404": ok("Not found.") }
|
|
1168
|
+
}
|
|
1169
|
+
},
|
|
1170
|
+
"/2/tweets/{id}": {
|
|
1171
|
+
get: {
|
|
1172
|
+
operationId: "findTweetById",
|
|
1173
|
+
summary: "Get a Tweet by id",
|
|
1174
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1175
|
+
security: [{ BearerToken: [] }, { OAuth2UserToken: ["tweet.read"] }],
|
|
1176
|
+
responses: { "200": ok("Tweet object."), "404": ok("Not found.") }
|
|
1177
|
+
},
|
|
1178
|
+
delete: {
|
|
1179
|
+
operationId: "deleteTweetById",
|
|
1180
|
+
summary: "Delete a Tweet",
|
|
1181
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1182
|
+
security: [{ OAuth2UserToken: ["tweet.write"] }],
|
|
1183
|
+
responses: { "200": ok("Deletion result."), "403": ok("Insufficient scope.") }
|
|
1184
|
+
}
|
|
1185
|
+
},
|
|
1186
|
+
"/2/tweets": {
|
|
1187
|
+
get: {
|
|
1188
|
+
operationId: "findTweetsById",
|
|
1189
|
+
summary: "Get Tweets by ids",
|
|
1190
|
+
parameters: [{ name: "ids", in: "query", required: true, schema: { type: "string" } }],
|
|
1191
|
+
security: [{ BearerToken: [] }, { OAuth2UserToken: ["tweet.read"] }],
|
|
1192
|
+
responses: { "200": ok("Tweet list."), "400": ok("Invalid request.") }
|
|
1193
|
+
},
|
|
1194
|
+
post: {
|
|
1195
|
+
operationId: "createTweet",
|
|
1196
|
+
summary: "Create a Tweet",
|
|
1197
|
+
security: [{ OAuth2UserToken: ["tweet.write"] }],
|
|
1198
|
+
requestBody: {
|
|
1199
|
+
required: true,
|
|
1200
|
+
content: {
|
|
1201
|
+
"application/json": {
|
|
1202
|
+
schema: { type: "object", required: ["text"], properties: { text: { type: "string" } } }
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
responses: { "201": ok("Created Tweet."), "403": ok("Insufficient scope.") }
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
var manifest = {
|
|
1213
|
+
id: "x",
|
|
1214
|
+
name: "X",
|
|
1215
|
+
description: "Stateful X (formerly Twitter) API v2 emulator focused on faithful auth: app-only Bearer tokens, OAuth 2.0 Authorization Code with PKCE, and a documented-partial legacy OAuth 1.0a surface.",
|
|
1216
|
+
docsUrl: "https://docs.emulators.dev/x",
|
|
1217
|
+
surfaces: [
|
|
1218
|
+
{ id: "rest", kind: "rest", title: "X API v2 (REST)", status: "partial", basePath: "/2" },
|
|
1219
|
+
{
|
|
1220
|
+
id: "oauth2",
|
|
1221
|
+
kind: "oauth",
|
|
1222
|
+
title: "OAuth 2.0 Authorization Code (PKCE)",
|
|
1223
|
+
status: "supported",
|
|
1224
|
+
basePath: "/2/oauth2"
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
id: "app-only",
|
|
1228
|
+
kind: "oauth",
|
|
1229
|
+
title: "App-only Bearer token (client credentials)",
|
|
1230
|
+
status: "supported",
|
|
1231
|
+
basePath: "/2/oauth2/token"
|
|
1232
|
+
},
|
|
1233
|
+
{
|
|
1234
|
+
id: "oauth1",
|
|
1235
|
+
kind: "provider-specific",
|
|
1236
|
+
title: "Legacy OAuth 1.0a user context",
|
|
1237
|
+
status: "unsupported",
|
|
1238
|
+
notes: "Declared for fidelity. Signature validation is not implemented."
|
|
1239
|
+
}
|
|
1240
|
+
],
|
|
1241
|
+
auth: [
|
|
1242
|
+
{
|
|
1243
|
+
id: "bearer-token",
|
|
1244
|
+
title: "App-only Bearer token",
|
|
1245
|
+
type: "bearer-token",
|
|
1246
|
+
status: "supported",
|
|
1247
|
+
notes: "Minted via POST /2/oauth2/token grant_type=client_credentials with HTTP Basic client auth."
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
id: "oauth2-user",
|
|
1251
|
+
title: "OAuth 2.0 Authorization Code with PKCE",
|
|
1252
|
+
type: "oauth-authorization-code",
|
|
1253
|
+
status: "supported",
|
|
1254
|
+
notes: "Confidential clients authenticate with client_secret_basic (HTTP Basic header) only; X does not support client_secret_post, so a secret in the request body is rejected. Public clients send client_id in the body and rely on PKCE (S256). offline.access yields a refresh token."
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
id: "oauth1-user",
|
|
1258
|
+
title: "Legacy OAuth 1.0a user context",
|
|
1259
|
+
type: "provider-specific",
|
|
1260
|
+
status: "unsupported",
|
|
1261
|
+
notes: "Accepted honestly as a partial surface; OAuth 1.0a request signing is not emulated."
|
|
1262
|
+
}
|
|
1263
|
+
],
|
|
1264
|
+
specs: [
|
|
1265
|
+
{
|
|
1266
|
+
kind: "openapi",
|
|
1267
|
+
title: "X API v2 subset",
|
|
1268
|
+
coverage: "hand-authored",
|
|
1269
|
+
url: "/2/openapi.json",
|
|
1270
|
+
operations: [
|
|
1271
|
+
{
|
|
1272
|
+
operationId: "oauth2Token",
|
|
1273
|
+
method: "POST",
|
|
1274
|
+
path: "/2/oauth2/token",
|
|
1275
|
+
status: "hand-authored",
|
|
1276
|
+
summary: "authorization_code (PKCE), refresh_token, and client_credentials grants."
|
|
1277
|
+
},
|
|
1278
|
+
{ operationId: "oauth2Revoke", method: "POST", path: "/2/oauth2/revoke", status: "hand-authored" },
|
|
1279
|
+
{
|
|
1280
|
+
operationId: "findMyUser",
|
|
1281
|
+
method: "GET",
|
|
1282
|
+
path: "/2/users/me",
|
|
1283
|
+
status: "hand-authored",
|
|
1284
|
+
summary: "User-context token with users.read."
|
|
1285
|
+
},
|
|
1286
|
+
{ operationId: "findUserById", method: "GET", path: "/2/users/:id", status: "hand-authored" },
|
|
1287
|
+
{
|
|
1288
|
+
operationId: "findUserByUsername",
|
|
1289
|
+
method: "GET",
|
|
1290
|
+
path: "/2/users/by/username/:username",
|
|
1291
|
+
status: "hand-authored"
|
|
1292
|
+
},
|
|
1293
|
+
{ operationId: "usersIdTweets", method: "GET", path: "/2/users/:id/tweets", status: "hand-authored" },
|
|
1294
|
+
{ operationId: "findTweetById", method: "GET", path: "/2/tweets/:id", status: "hand-authored" },
|
|
1295
|
+
{ operationId: "findTweetsById", method: "GET", path: "/2/tweets", status: "hand-authored" },
|
|
1296
|
+
{
|
|
1297
|
+
operationId: "createTweet",
|
|
1298
|
+
method: "POST",
|
|
1299
|
+
path: "/2/tweets",
|
|
1300
|
+
status: "hand-authored",
|
|
1301
|
+
summary: "User-context token with tweet.write."
|
|
1302
|
+
},
|
|
1303
|
+
{ operationId: "deleteTweetById", method: "DELETE", path: "/2/tweets/:id", status: "hand-authored" },
|
|
1304
|
+
{
|
|
1305
|
+
operationId: "tweetsRecentSearch",
|
|
1306
|
+
method: "GET",
|
|
1307
|
+
path: "/2/tweets/search/recent",
|
|
1308
|
+
status: "unsupported"
|
|
1309
|
+
},
|
|
1310
|
+
{ operationId: "usersIdFollow", method: "POST", path: "/2/users/:id/following", status: "unsupported" },
|
|
1311
|
+
{ operationId: "usersIdLike", method: "POST", path: "/2/users/:id/likes", status: "unsupported" }
|
|
1312
|
+
]
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
kind: "oauth-metadata",
|
|
1316
|
+
title: "OAuth 2.0 client-auth behavior (confidential clients use client_secret_basic only)",
|
|
1317
|
+
coverage: "hand-authored"
|
|
1318
|
+
}
|
|
1319
|
+
],
|
|
1320
|
+
scenarios: [
|
|
1321
|
+
{
|
|
1322
|
+
id: "default",
|
|
1323
|
+
title: "Single developer account",
|
|
1324
|
+
description: "One verified user with a seeded confidential and public OAuth client."
|
|
1325
|
+
}
|
|
1326
|
+
],
|
|
1327
|
+
seedSchema: {
|
|
1328
|
+
description: "Seed users, OAuth 2.0 clients (confidential or public), and Tweets.",
|
|
1329
|
+
fields: [
|
|
1330
|
+
{
|
|
1331
|
+
key: "users",
|
|
1332
|
+
title: "Users",
|
|
1333
|
+
description: "X accounts addressable by username (@handle) and numeric id.",
|
|
1334
|
+
example: [{ username: "developer", name: "Developer", verified: true }]
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
key: "oauth_clients",
|
|
1338
|
+
title: "OAuth 2.0 clients",
|
|
1339
|
+
description: "Confidential clients have a client_secret and use HTTP Basic auth; public clients omit it and use PKCE only.",
|
|
1340
|
+
example: [
|
|
1341
|
+
{
|
|
1342
|
+
client_id: "x-confidential-client",
|
|
1343
|
+
client_secret: "x-confidential-secret",
|
|
1344
|
+
client_type: "confidential",
|
|
1345
|
+
name: "My X App",
|
|
1346
|
+
redirect_uris: ["http://localhost:3000/api/auth/callback/twitter"]
|
|
1347
|
+
},
|
|
1348
|
+
{ client_id: "x-public-client", client_type: "public", name: "My X SPA" }
|
|
1349
|
+
]
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
key: "tweets",
|
|
1353
|
+
title: "Tweets",
|
|
1354
|
+
description: "Tweets authored by a seeded user (referenced by username or id).",
|
|
1355
|
+
example: [{ text: "Hello from X.", author: "developer", like_count: 42 }]
|
|
1356
|
+
}
|
|
1357
|
+
],
|
|
1358
|
+
example: {
|
|
1359
|
+
users: [{ username: "developer", name: "Developer", verified: true, followers_count: 1200 }],
|
|
1360
|
+
oauth_clients: [
|
|
1361
|
+
{
|
|
1362
|
+
client_id: "x-confidential-client",
|
|
1363
|
+
client_secret: "x-confidential-secret",
|
|
1364
|
+
client_type: "confidential",
|
|
1365
|
+
name: "My X App",
|
|
1366
|
+
redirect_uris: ["http://localhost:3000/api/auth/callback/twitter"]
|
|
1367
|
+
},
|
|
1368
|
+
{ client_id: "x-public-client", client_type: "public", name: "My X SPA" }
|
|
1369
|
+
],
|
|
1370
|
+
tweets: [{ text: "Hello from the X API v2 emulator.", author: "developer", like_count: 42, retweet_count: 7 }]
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
stateModel: {
|
|
1374
|
+
description: "Entities seeded and mutated by X provider calls.",
|
|
1375
|
+
collections: [
|
|
1376
|
+
{ name: "x.users", title: "Users" },
|
|
1377
|
+
{ name: "x.tweets", title: "Tweets" },
|
|
1378
|
+
{ name: "x.oauth_clients", title: "OAuth 2.0 clients" },
|
|
1379
|
+
{ name: "x.auth_codes", title: "Authorization codes" },
|
|
1380
|
+
{ name: "x.access_tokens", title: "Access tokens" },
|
|
1381
|
+
{ name: "x.refresh_tokens", title: "Refresh tokens" }
|
|
1382
|
+
]
|
|
1383
|
+
},
|
|
1384
|
+
connections: [
|
|
1385
|
+
{
|
|
1386
|
+
id: "twitter-api-v2",
|
|
1387
|
+
title: "twitter-api-v2 (TypeScript)",
|
|
1388
|
+
kind: "sdk",
|
|
1389
|
+
language: "typescript",
|
|
1390
|
+
description: "Point the twitter-api-v2 SDK at the emulator for both app-only and user-context auth.",
|
|
1391
|
+
template: 'import { TwitterApi } from "twitter-api-v2";\n\nconst base = "{{baseUrl}}";\n\n// App-only Bearer token (client_credentials).\nconst appClient = new TwitterApi("{{token}}", { baseUrl: base });\nconst user = await appClient.v2.userByUsername("developer");\n\n// OAuth 2.0 user-context client (Authorization Code with PKCE).\nconst oauthClient = new TwitterApi({ clientId: "{{clientId}}", clientSecret: "{{clientSecret}}" });\nconst { url, codeVerifier, state } = oauthClient.generateOAuth2AuthLink(\n "http://localhost:3000/api/auth/callback/twitter",\n { scope: ["tweet.read", "tweet.write", "users.read", "offline.access"] },\n);\n// Send the user to `url` (against `${base}/2/oauth2/authorize`), then exchange the code at `${base}/2/oauth2/token`.'
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
id: "x-env",
|
|
1395
|
+
title: "X base URL and credentials (env)",
|
|
1396
|
+
kind: "env",
|
|
1397
|
+
language: "bash",
|
|
1398
|
+
description: "Point your app at the emulator instead of api.x.com.",
|
|
1399
|
+
template: "X_API_BASE_URL={{baseUrl}}\nX_CLIENT_ID={{clientId}}\nX_CLIENT_SECRET={{clientSecret}}\nX_BEARER_TOKEN={{token}}"
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
id: "curl-app-only",
|
|
1403
|
+
title: "curl (app-only Bearer)",
|
|
1404
|
+
kind: "curl",
|
|
1405
|
+
language: "bash",
|
|
1406
|
+
description: "Mint an app-only Bearer token (confidential client, HTTP Basic auth) and read a user.",
|
|
1407
|
+
template: 'curl -s -X POST {{baseUrl}}/2/oauth2/token \\\n -u "{{clientId}}:{{clientSecret}}" \\\n -d grant_type=client_credentials\n\ncurl -s {{baseUrl}}/2/users/by/username/developer \\\n -H "authorization: Bearer {{token}}"'
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
id: "curl-token-exchange",
|
|
1411
|
+
title: "curl (authorization code + PKCE)",
|
|
1412
|
+
kind: "curl",
|
|
1413
|
+
language: "bash",
|
|
1414
|
+
description: "Exchange an authorization code for a user-context token. Confidential clients use -u (Basic); public clients send client_id in the body with no secret.",
|
|
1415
|
+
template: 'curl -s -X POST {{baseUrl}}/2/oauth2/token \\\n -u "{{clientId}}:{{clientSecret}}" \\\n -d grant_type=authorization_code \\\n -d code=$CODE \\\n -d redirect_uri=http://localhost:3000/api/auth/callback/twitter \\\n -d code_verifier=$CODE_VERIFIER'
|
|
1416
|
+
}
|
|
1417
|
+
]
|
|
1418
|
+
};
|
|
1419
|
+
function resolveAuthor(store, ref) {
|
|
1420
|
+
const xs = getXStore(store);
|
|
1421
|
+
const byId = xs.users.findOneBy("user_id", ref);
|
|
1422
|
+
if (byId) return byId.user_id;
|
|
1423
|
+
const byUsername = xs.users.findOneBy("username", ref.toLowerCase().replace(/^@/, ""));
|
|
1424
|
+
return byUsername ? byUsername.user_id : null;
|
|
1425
|
+
}
|
|
1426
|
+
function seedFromConfig(store, baseUrl, config) {
|
|
1427
|
+
const xs = getXStore(store);
|
|
1428
|
+
for (const u of config.users ?? []) {
|
|
1429
|
+
const username = u.username.toLowerCase().replace(/^@/, "");
|
|
1430
|
+
if (xs.users.findOneBy("username", username)) continue;
|
|
1431
|
+
const userId = u.user_id ?? xNumericId();
|
|
1432
|
+
xs.users.insert({
|
|
1433
|
+
user_id: userId,
|
|
1434
|
+
username,
|
|
1435
|
+
name: u.name ?? u.username,
|
|
1436
|
+
description: u.description ?? "",
|
|
1437
|
+
verified: u.verified ?? false,
|
|
1438
|
+
protected: u.protected ?? false,
|
|
1439
|
+
location: u.location ?? null,
|
|
1440
|
+
url: u.url ?? null,
|
|
1441
|
+
profile_image_url: u.profile_image_url ?? `${baseUrl}/profile_images/${username}.png`,
|
|
1442
|
+
followers_count: u.followers_count ?? 0,
|
|
1443
|
+
following_count: u.following_count ?? 0,
|
|
1444
|
+
tweet_count: 0,
|
|
1445
|
+
listed_count: u.listed_count ?? 0,
|
|
1446
|
+
created_at_x: u.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
for (const cl of config.oauth_clients ?? []) {
|
|
1450
|
+
if (xs.oauthClients.findOneBy("client_id", cl.client_id)) continue;
|
|
1451
|
+
const clientType = cl.client_type ?? (cl.client_secret ? "confidential" : "public");
|
|
1452
|
+
xs.oauthClients.insert({
|
|
1453
|
+
client_id: cl.client_id,
|
|
1454
|
+
client_secret: clientType === "confidential" ? cl.client_secret ?? null : null,
|
|
1455
|
+
client_type: clientType,
|
|
1456
|
+
name: cl.name ?? cl.client_id,
|
|
1457
|
+
redirect_uris: cl.redirect_uris ?? ["http://localhost:3000/callback"]
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
for (const t of config.tweets ?? []) {
|
|
1461
|
+
const authorId = resolveAuthor(store, t.author);
|
|
1462
|
+
if (!authorId) continue;
|
|
1463
|
+
const tweetId = t.tweet_id ?? xNumericId();
|
|
1464
|
+
if (xs.tweets.findOneBy("tweet_id", tweetId)) continue;
|
|
1465
|
+
xs.tweets.insert({
|
|
1466
|
+
tweet_id: tweetId,
|
|
1467
|
+
author_id: authorId,
|
|
1468
|
+
text: t.text,
|
|
1469
|
+
reply_count: t.reply_count ?? 0,
|
|
1470
|
+
retweet_count: t.retweet_count ?? 0,
|
|
1471
|
+
like_count: t.like_count ?? 0,
|
|
1472
|
+
quote_count: t.quote_count ?? 0,
|
|
1473
|
+
impression_count: t.impression_count ?? 0,
|
|
1474
|
+
in_reply_to_user_id: null,
|
|
1475
|
+
conversation_id: tweetId,
|
|
1476
|
+
lang: t.lang ?? "en",
|
|
1477
|
+
possibly_sensitive: false,
|
|
1478
|
+
created_at_x: t.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1479
|
+
});
|
|
1480
|
+
const author = xs.users.findOneBy("user_id", authorId);
|
|
1481
|
+
if (author) xs.users.update(author.id, { tweet_count: author.tweet_count + 1 });
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
var xPlugin = {
|
|
1485
|
+
name: "x",
|
|
1486
|
+
register(app, store, webhooks, baseUrl, tokenMap) {
|
|
1487
|
+
const ctx = { app, store, webhooks, baseUrl, tokenMap };
|
|
1488
|
+
oauthRoutes(ctx);
|
|
1489
|
+
usersRoutes(ctx);
|
|
1490
|
+
tweetsRoutes(ctx);
|
|
1491
|
+
openapiRoutes(ctx);
|
|
1492
|
+
},
|
|
1493
|
+
seed(store, baseUrl) {
|
|
1494
|
+
seedFromConfig(store, baseUrl, {
|
|
1495
|
+
users: [
|
|
1496
|
+
{
|
|
1497
|
+
username: "developer",
|
|
1498
|
+
name: "Developer",
|
|
1499
|
+
description: "Building with the X API v2 emulator.",
|
|
1500
|
+
verified: true,
|
|
1501
|
+
followers_count: 1200,
|
|
1502
|
+
following_count: 320
|
|
1503
|
+
}
|
|
1504
|
+
],
|
|
1505
|
+
oauth_clients: [
|
|
1506
|
+
{
|
|
1507
|
+
client_id: "x-confidential-client",
|
|
1508
|
+
client_secret: "x-confidential-secret",
|
|
1509
|
+
client_type: "confidential",
|
|
1510
|
+
name: "My X App (confidential)",
|
|
1511
|
+
redirect_uris: ["http://localhost:3000/api/auth/callback/twitter"]
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
client_id: "x-public-client",
|
|
1515
|
+
client_type: "public",
|
|
1516
|
+
name: "My X App (public)",
|
|
1517
|
+
redirect_uris: ["http://localhost:3000/api/auth/callback/twitter"]
|
|
1518
|
+
}
|
|
1519
|
+
],
|
|
1520
|
+
tweets: [{ text: "Hello from the X API v2 emulator.", author: "developer", like_count: 42, retweet_count: 7 }]
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
var index_default = xPlugin;
|
|
1525
|
+
export {
|
|
1526
|
+
index_default as default,
|
|
1527
|
+
getXStore,
|
|
1528
|
+
lookupAccessToken,
|
|
1529
|
+
manifest,
|
|
1530
|
+
seedFromConfig,
|
|
1531
|
+
xPlugin
|
|
1532
|
+
};
|
|
1533
|
+
//# sourceMappingURL=dist-OYYGWKZQ.js.map
|