@emulators/core 0.3.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/dist/index.js ADDED
@@ -0,0 +1,839 @@
1
+ // src/store.ts
2
+ var Collection = class {
3
+ constructor(indexFields = []) {
4
+ this.indexFields = indexFields;
5
+ this.fieldNames = indexFields.map(String).sort();
6
+ for (const field of indexFields) {
7
+ this.indexes.set(String(field), /* @__PURE__ */ new Map());
8
+ }
9
+ }
10
+ items = /* @__PURE__ */ new Map();
11
+ indexes = /* @__PURE__ */ new Map();
12
+ autoId = 1;
13
+ fieldNames;
14
+ addToIndex(item) {
15
+ for (const field of this.indexFields) {
16
+ const value = item[field];
17
+ if (value === void 0 || value === null) continue;
18
+ const indexMap = this.indexes.get(String(field));
19
+ const key = String(value);
20
+ if (!indexMap.has(key)) {
21
+ indexMap.set(key, /* @__PURE__ */ new Set());
22
+ }
23
+ indexMap.get(key).add(item.id);
24
+ }
25
+ }
26
+ removeFromIndex(item) {
27
+ for (const field of this.indexFields) {
28
+ const value = item[field];
29
+ if (value === void 0 || value === null) continue;
30
+ const indexMap = this.indexes.get(String(field));
31
+ const key = String(value);
32
+ indexMap.get(key)?.delete(item.id);
33
+ }
34
+ }
35
+ insert(data) {
36
+ const now = (/* @__PURE__ */ new Date()).toISOString();
37
+ const explicitId = data.id != null && data.id > 0 ? data.id : void 0;
38
+ const id = explicitId ?? this.autoId++;
39
+ if (id >= this.autoId) {
40
+ this.autoId = id + 1;
41
+ }
42
+ const item = {
43
+ ...data,
44
+ id,
45
+ created_at: now,
46
+ updated_at: now
47
+ };
48
+ this.items.set(id, item);
49
+ this.addToIndex(item);
50
+ return item;
51
+ }
52
+ get(id) {
53
+ return this.items.get(id);
54
+ }
55
+ findBy(field, value) {
56
+ if (this.indexes.has(String(field))) {
57
+ const ids = this.indexes.get(String(field)).get(String(value));
58
+ if (!ids) return [];
59
+ return Array.from(ids).map((id) => this.items.get(id)).filter(Boolean);
60
+ }
61
+ return this.all().filter((item) => item[field] === value);
62
+ }
63
+ findOneBy(field, value) {
64
+ return this.findBy(field, value)[0];
65
+ }
66
+ update(id, data) {
67
+ const existing = this.items.get(id);
68
+ if (!existing) return void 0;
69
+ this.removeFromIndex(existing);
70
+ const updated = {
71
+ ...existing,
72
+ ...data,
73
+ id,
74
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
75
+ };
76
+ this.items.set(id, updated);
77
+ this.addToIndex(updated);
78
+ return updated;
79
+ }
80
+ delete(id) {
81
+ const existing = this.items.get(id);
82
+ if (!existing) return false;
83
+ this.removeFromIndex(existing);
84
+ return this.items.delete(id);
85
+ }
86
+ all() {
87
+ return Array.from(this.items.values());
88
+ }
89
+ query(options = {}) {
90
+ let results = this.all();
91
+ if (options.filter) {
92
+ results = results.filter(options.filter);
93
+ }
94
+ const total_count = results.length;
95
+ if (options.sort) {
96
+ results.sort(options.sort);
97
+ }
98
+ const page = options.page ?? 1;
99
+ const per_page = Math.min(options.per_page ?? 30, 100);
100
+ const start = (page - 1) * per_page;
101
+ const paged = results.slice(start, start + per_page);
102
+ return {
103
+ items: paged,
104
+ total_count,
105
+ page,
106
+ per_page,
107
+ has_next: start + per_page < total_count,
108
+ has_prev: page > 1
109
+ };
110
+ }
111
+ count(filter) {
112
+ if (!filter) return this.items.size;
113
+ return this.all().filter(filter).length;
114
+ }
115
+ clear() {
116
+ this.items.clear();
117
+ for (const indexMap of this.indexes.values()) {
118
+ indexMap.clear();
119
+ }
120
+ this.autoId = 1;
121
+ }
122
+ };
123
+ var Store = class {
124
+ collections = /* @__PURE__ */ new Map();
125
+ _data = /* @__PURE__ */ new Map();
126
+ collection(name, indexFields = []) {
127
+ const existing = this.collections.get(name);
128
+ if (existing) {
129
+ if (indexFields.length > 0) {
130
+ const requested = indexFields.map(String).sort();
131
+ if (existing.fieldNames.length !== requested.length || existing.fieldNames.some((f, i) => f !== requested[i])) {
132
+ throw new Error(
133
+ `Collection "${name}" already exists with indexes [${existing.fieldNames}] but was requested with [${requested}]`
134
+ );
135
+ }
136
+ }
137
+ return existing;
138
+ }
139
+ const col = new Collection(indexFields);
140
+ this.collections.set(name, col);
141
+ return col;
142
+ }
143
+ getData(key) {
144
+ return this._data.get(key);
145
+ }
146
+ setData(key, value) {
147
+ this._data.set(key, value);
148
+ }
149
+ reset() {
150
+ for (const collection of this.collections.values()) {
151
+ collection.clear();
152
+ }
153
+ this._data.clear();
154
+ }
155
+ };
156
+
157
+ // src/server.ts
158
+ import { Hono } from "hono";
159
+ import { cors } from "hono/cors";
160
+
161
+ // src/webhooks.ts
162
+ import { createHmac } from "crypto";
163
+ var MAX_DELIVERIES = 1e3;
164
+ var WebhookDispatcher = class {
165
+ subscriptions = [];
166
+ deliveries = [];
167
+ subscriptionIdCounter = 1;
168
+ deliveryIdCounter = 1;
169
+ register(sub) {
170
+ const { id: explicitId, ...rest } = sub;
171
+ const id = explicitId !== void 0 ? explicitId : this.subscriptionIdCounter++;
172
+ if (id >= this.subscriptionIdCounter) {
173
+ this.subscriptionIdCounter = id + 1;
174
+ }
175
+ const subscription = { ...rest, id };
176
+ this.subscriptions.push(subscription);
177
+ return subscription;
178
+ }
179
+ unregister(id) {
180
+ const idx = this.subscriptions.findIndex((s) => s.id === id);
181
+ if (idx === -1) return false;
182
+ this.subscriptions.splice(idx, 1);
183
+ return true;
184
+ }
185
+ getSubscription(id) {
186
+ return this.subscriptions.find((s) => s.id === id);
187
+ }
188
+ getSubscriptions(owner, repo) {
189
+ return this.subscriptions.filter((s) => {
190
+ if (owner && s.owner !== owner) return false;
191
+ if (repo !== void 0 && s.repo !== repo) return false;
192
+ return true;
193
+ });
194
+ }
195
+ updateSubscription(id, data) {
196
+ const sub = this.subscriptions.find((s) => s.id === id);
197
+ if (!sub) return void 0;
198
+ Object.assign(sub, data);
199
+ return sub;
200
+ }
201
+ async dispatch(event, action, payload, owner, repo) {
202
+ const matchingSubs = this.subscriptions.filter((s) => {
203
+ if (!s.active) return false;
204
+ if (s.owner !== owner) return false;
205
+ if (repo !== void 0) {
206
+ if (s.repo !== repo) return false;
207
+ } else if (s.repo !== void 0) {
208
+ return false;
209
+ }
210
+ return event === "ping" || s.events.includes("*") || s.events.includes(event);
211
+ });
212
+ for (const sub of matchingSubs) {
213
+ const delivery = {
214
+ id: this.deliveryIdCounter++,
215
+ hook_id: sub.id,
216
+ event,
217
+ action,
218
+ payload,
219
+ status_code: null,
220
+ delivered_at: (/* @__PURE__ */ new Date()).toISOString(),
221
+ duration: null,
222
+ success: false
223
+ };
224
+ const body = JSON.stringify(payload);
225
+ const signatureHeaders = {};
226
+ if (sub.secret) {
227
+ const hmac = createHmac("sha256", sub.secret).update(body).digest("hex");
228
+ signatureHeaders["X-Hub-Signature-256"] = `sha256=${hmac}`;
229
+ }
230
+ try {
231
+ const start = Date.now();
232
+ const response = await fetch(sub.url, {
233
+ method: "POST",
234
+ headers: {
235
+ "Content-Type": "application/json",
236
+ "X-GitHub-Event": event,
237
+ "X-GitHub-Delivery": String(delivery.id),
238
+ ...signatureHeaders
239
+ },
240
+ body,
241
+ signal: AbortSignal.timeout(1e4)
242
+ });
243
+ delivery.duration = Date.now() - start;
244
+ delivery.status_code = response.status;
245
+ delivery.success = response.ok;
246
+ } catch {
247
+ delivery.duration = 0;
248
+ delivery.success = false;
249
+ }
250
+ this.deliveries.push(delivery);
251
+ if (this.deliveries.length > MAX_DELIVERIES) {
252
+ this.deliveries.splice(0, this.deliveries.length - MAX_DELIVERIES);
253
+ }
254
+ }
255
+ }
256
+ getDeliveries(hookId) {
257
+ if (hookId !== void 0) {
258
+ return this.deliveries.filter((d) => d.hook_id === hookId);
259
+ }
260
+ return [...this.deliveries];
261
+ }
262
+ clear() {
263
+ this.subscriptions.length = 0;
264
+ this.deliveries.length = 0;
265
+ this.subscriptionIdCounter = 1;
266
+ this.deliveryIdCounter = 1;
267
+ }
268
+ };
269
+
270
+ // src/middleware/error-handler.ts
271
+ var DEFAULT_DOCS_URL = "https://emulate.dev";
272
+ function getDocsUrl(c) {
273
+ return c.get("docsUrl") ?? DEFAULT_DOCS_URL;
274
+ }
275
+ function errorStatus(err) {
276
+ if (err && typeof err === "object" && "status" in err) {
277
+ const s = err.status;
278
+ if (typeof s === "number" && Number.isFinite(s)) return s;
279
+ }
280
+ return 500;
281
+ }
282
+ function createApiErrorHandler(documentationUrl) {
283
+ return (err, c) => {
284
+ if (documentationUrl) {
285
+ c.set("docsUrl", documentationUrl);
286
+ }
287
+ const status = errorStatus(err);
288
+ const message = err instanceof Error ? err.message : "Internal Server Error";
289
+ return c.json(
290
+ {
291
+ message,
292
+ documentation_url: getDocsUrl(c)
293
+ },
294
+ status
295
+ );
296
+ };
297
+ }
298
+ function createErrorHandler(documentationUrl) {
299
+ return async (c, next) => {
300
+ if (documentationUrl) {
301
+ c.set("docsUrl", documentationUrl);
302
+ }
303
+ await next();
304
+ };
305
+ }
306
+ var errorHandler = createErrorHandler();
307
+ var ApiError = class extends Error {
308
+ constructor(status, message, errors) {
309
+ super(message);
310
+ this.status = status;
311
+ this.errors = errors;
312
+ this.name = "ApiError";
313
+ }
314
+ };
315
+ function notFound(resource) {
316
+ return new ApiError(404, resource ? `${resource} not found` : "Not Found");
317
+ }
318
+ function validationError(message, errors) {
319
+ return new ApiError(422, message, errors);
320
+ }
321
+ function unauthorized() {
322
+ return new ApiError(401, "Requires authentication");
323
+ }
324
+ function forbidden() {
325
+ return new ApiError(403, "Forbidden");
326
+ }
327
+ async function parseJsonBody(c) {
328
+ try {
329
+ const body = await c.req.json();
330
+ if (body && typeof body === "object" && !Array.isArray(body)) {
331
+ return body;
332
+ }
333
+ return {};
334
+ } catch {
335
+ throw new ApiError(400, "Problems parsing JSON");
336
+ }
337
+ }
338
+
339
+ // src/middleware/auth.ts
340
+ import { jwtVerify, importPKCS8 } from "jose";
341
+
342
+ // src/debug.ts
343
+ var isDebug = typeof process !== "undefined" && (process.env.DEBUG === "1" || process.env.DEBUG === "true" || process.env.EMULATE_DEBUG === "1");
344
+ function debug(label, ...args) {
345
+ if (isDebug) {
346
+ console.log(`[${label}]`, ...args);
347
+ }
348
+ }
349
+
350
+ // src/middleware/auth.ts
351
+ function authMiddleware(tokens, appKeyResolver, fallbackUser) {
352
+ return async (c, next) => {
353
+ const authHeader = c.req.header("Authorization");
354
+ if (authHeader) {
355
+ const token = authHeader.replace(/^(Bearer|token)\s+/i, "").trim();
356
+ if (token.startsWith("eyJ") && appKeyResolver) {
357
+ try {
358
+ const [, payloadB64] = token.split(".");
359
+ const payload = JSON.parse(
360
+ Buffer.from(payloadB64, "base64url").toString()
361
+ );
362
+ const appId = typeof payload.iss === "string" ? parseInt(payload.iss, 10) : payload.iss;
363
+ if (typeof appId === "number" && !isNaN(appId)) {
364
+ const appInfo = appKeyResolver(appId);
365
+ if (appInfo) {
366
+ const key = await importPKCS8(appInfo.privateKey, "RS256");
367
+ await jwtVerify(token, key, { algorithms: ["RS256"] });
368
+ c.set("authApp", {
369
+ appId,
370
+ slug: appInfo.slug,
371
+ name: appInfo.name
372
+ });
373
+ }
374
+ }
375
+ } catch {
376
+ }
377
+ } else {
378
+ let user = tokens.get(token);
379
+ if (!user && fallbackUser && token.length > 0) {
380
+ debug("auth", "fallback user for unknown token", { login: fallbackUser.login, id: fallbackUser.id });
381
+ user = { login: fallbackUser.login, id: fallbackUser.id, scopes: fallbackUser.scopes };
382
+ }
383
+ if (user) {
384
+ c.set("authUser", user);
385
+ c.set("authToken", token);
386
+ c.set("authScopes", user.scopes);
387
+ }
388
+ }
389
+ }
390
+ await next();
391
+ };
392
+ }
393
+ function requireAuth() {
394
+ return async (c, next) => {
395
+ if (!c.get("authUser")) {
396
+ const docsUrl = c.get("docsUrl") ?? "https://emulate.dev";
397
+ return c.json(
398
+ {
399
+ message: "Requires authentication",
400
+ documentation_url: docsUrl
401
+ },
402
+ 401
403
+ );
404
+ }
405
+ await next();
406
+ };
407
+ }
408
+ function requireAppAuth() {
409
+ return async (c, next) => {
410
+ if (!c.get("authApp")) {
411
+ const docsUrl = c.get("docsUrl") ?? "https://emulate.dev";
412
+ return c.json(
413
+ {
414
+ message: "A JSON web token could not be decoded",
415
+ documentation_url: docsUrl
416
+ },
417
+ 401
418
+ );
419
+ }
420
+ await next();
421
+ };
422
+ }
423
+
424
+ // src/fonts.ts
425
+ import { readFileSync } from "fs";
426
+ import { fileURLToPath } from "url";
427
+ import { dirname, join } from "path";
428
+ var __dirname = dirname(fileURLToPath(import.meta.url));
429
+ var FONTS = {
430
+ "geist-sans.woff2": readFileSync(join(__dirname, "fonts", "geist-sans.woff2")),
431
+ "GeistPixel-Square.woff2": readFileSync(join(__dirname, "fonts", "GeistPixel-Square.woff2"))
432
+ };
433
+ function registerFontRoutes(app) {
434
+ app.get("/_emulate/fonts/:name", (c) => {
435
+ const name = c.req.param("name");
436
+ const buf = FONTS[name];
437
+ if (!buf) return c.notFound();
438
+ return new Response(buf, {
439
+ headers: {
440
+ "Content-Type": "font/woff2",
441
+ "Cache-Control": "public, max-age=31536000, immutable",
442
+ "Access-Control-Allow-Origin": "*"
443
+ }
444
+ });
445
+ });
446
+ }
447
+
448
+ // src/server.ts
449
+ function createServer(plugin, options = {}) {
450
+ const port = options.port ?? 4e3;
451
+ const baseUrl = options.baseUrl ?? `http://localhost:${port}`;
452
+ const app = new Hono();
453
+ const store = new Store();
454
+ const webhooks = new WebhookDispatcher();
455
+ const tokenMap = /* @__PURE__ */ new Map();
456
+ if (options.tokens) {
457
+ for (const [token, user] of Object.entries(options.tokens)) {
458
+ tokenMap.set(token, {
459
+ login: user.login,
460
+ id: user.id,
461
+ scopes: user.scopes ?? ["repo", "user", "admin:org", "admin:repo_hook"]
462
+ });
463
+ }
464
+ }
465
+ const docsUrl = options.docsUrl ?? `https://emulate.dev/${plugin.name}`;
466
+ registerFontRoutes(app);
467
+ app.onError(createApiErrorHandler(docsUrl));
468
+ app.use("*", cors());
469
+ app.use("*", createErrorHandler(docsUrl));
470
+ app.use("*", authMiddleware(tokenMap, options.appKeyResolver, options.fallbackUser));
471
+ const rateLimitCounters = /* @__PURE__ */ new Map();
472
+ let lastPruneAt = Math.floor(Date.now() / 1e3);
473
+ app.use("*", async (c, next) => {
474
+ const token = c.get("authToken") ?? "__anonymous__";
475
+ const now = Math.floor(Date.now() / 1e3);
476
+ if (now - lastPruneAt > 3600) {
477
+ for (const [key, val] of rateLimitCounters) {
478
+ if (val.resetAt <= now) rateLimitCounters.delete(key);
479
+ }
480
+ lastPruneAt = now;
481
+ }
482
+ let counter = rateLimitCounters.get(token);
483
+ if (!counter || counter.resetAt <= now) {
484
+ counter = { remaining: 5e3, resetAt: now + 3600 };
485
+ rateLimitCounters.set(token, counter);
486
+ }
487
+ counter.remaining = Math.max(0, counter.remaining - 1);
488
+ c.header("X-RateLimit-Limit", "5000");
489
+ c.header("X-RateLimit-Remaining", String(counter.remaining));
490
+ c.header("X-RateLimit-Reset", String(counter.resetAt));
491
+ c.header("X-RateLimit-Resource", "core");
492
+ if (counter.remaining === 0) {
493
+ return c.json(
494
+ {
495
+ message: "API rate limit exceeded",
496
+ documentation_url: docsUrl
497
+ },
498
+ 403
499
+ );
500
+ }
501
+ await next();
502
+ });
503
+ plugin.register(app, store, webhooks, baseUrl, tokenMap);
504
+ app.notFound(
505
+ (c) => c.json(
506
+ {
507
+ message: "Not Found",
508
+ documentation_url: docsUrl
509
+ },
510
+ 404
511
+ )
512
+ );
513
+ return { app, store, webhooks, port, baseUrl, tokenMap };
514
+ }
515
+
516
+ // src/middleware/pagination.ts
517
+ function parsePagination(c) {
518
+ const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
519
+ const per_page = Math.min(100, Math.max(1, parseInt(c.req.query("per_page") ?? "30", 10) || 30));
520
+ return { page, per_page };
521
+ }
522
+ function setLinkHeader(c, totalCount, page, perPage) {
523
+ const lastPage = Math.max(1, Math.ceil(totalCount / perPage));
524
+ const baseUrl = new URL(c.req.url);
525
+ const links = [];
526
+ const makeLink = (p, rel) => {
527
+ baseUrl.searchParams.set("page", String(p));
528
+ baseUrl.searchParams.set("per_page", String(perPage));
529
+ return `<${baseUrl.toString()}>; rel="${rel}"`;
530
+ };
531
+ if (page < lastPage) {
532
+ links.push(makeLink(page + 1, "next"));
533
+ links.push(makeLink(lastPage, "last"));
534
+ }
535
+ if (page > 1) {
536
+ links.push(makeLink(1, "first"));
537
+ links.push(makeLink(page - 1, "prev"));
538
+ }
539
+ if (links.length > 0) {
540
+ c.header("Link", links.join(", "));
541
+ }
542
+ }
543
+
544
+ // src/ui.ts
545
+ function escapeHtml(s) {
546
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
547
+ }
548
+ function escapeAttr(s) {
549
+ return escapeHtml(s).replace(/'/g, "&#39;");
550
+ }
551
+ var CSS = `
552
+ @font-face{
553
+ font-family:'Geist';font-style:normal;font-weight:100 900;font-display:swap;
554
+ src:url('/_emulate/fonts/geist-sans.woff2') format('woff2');
555
+ }
556
+ @font-face{
557
+ font-family:'Geist Pixel';font-style:normal;font-weight:400;font-display:swap;
558
+ src:url('/_emulate/fonts/GeistPixel-Square.woff2') format('woff2');
559
+ }
560
+ *{box-sizing:border-box;margin:0;padding:0}
561
+ body{
562
+ font-family:'Geist',-apple-system,BlinkMacSystemFont,sans-serif;
563
+ background:#000;color:#33ff00;min-height:100vh;
564
+ -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
565
+ }
566
+ .emu-bar{
567
+ border-bottom:1px solid #0a3300;padding:10px 20px;
568
+ display:flex;align-items:center;gap:10px;font-size:.8125rem;color:#1a8c00;
569
+ }
570
+ .emu-bar-title{font-weight:600;color:#33ff00;font-family:'Geist Pixel',monospace;}
571
+ .emu-bar-links{margin-left:auto;display:flex;gap:16px;}
572
+ .emu-bar-links a{
573
+ color:#1a8c00;font-size:.75rem;text-decoration:none;transition:color .15s;
574
+ }
575
+ .emu-bar-links a:hover{color:#33ff00;}
576
+ .emu-bar-links a .full{display:inline;}
577
+ .emu-bar-links a .short{display:none;}
578
+ @media(max-width:600px){
579
+ .emu-bar-links a .full{display:none;}
580
+ .emu-bar-links a .short{display:inline;}
581
+ }
582
+
583
+ .content{
584
+ display:flex;align-items:center;justify-content:center;
585
+ min-height:calc(100vh - 42px);padding:24px 16px;
586
+ }
587
+ .content-inner{width:100%;max-width:420px;}
588
+ .card-title{
589
+ font-family:'Geist Pixel',monospace;
590
+ font-size:1.125rem;font-weight:600;margin-bottom:4px;color:#33ff00;
591
+ }
592
+ .card-subtitle{color:#1a8c00;font-size:.8125rem;margin-bottom:18px;line-height:1.45;}
593
+ .powered-by{
594
+ position:fixed;bottom:0;left:0;right:0;
595
+ text-align:center;padding:12px;font-size:.6875rem;color:#0a3300;
596
+ font-family:'Geist Pixel',monospace;
597
+ }
598
+ .powered-by a{color:#1a8c00;text-decoration:none;transition:color .15s;}
599
+ .powered-by a:hover{color:#33ff00;}
600
+
601
+ .error-title{
602
+ font-family:'Geist Pixel',monospace;
603
+ color:#ff4444;font-size:1.125rem;font-weight:600;margin-bottom:8px;
604
+ }
605
+ .error-msg{color:#1a8c00;font-size:.875rem;line-height:1.5;}
606
+ .error-card{text-align:center;}
607
+
608
+ .user-form{margin-bottom:8px;}
609
+ .user-form:last-of-type{margin-bottom:0;}
610
+ .user-btn{
611
+ width:100%;display:flex;align-items:center;gap:12px;
612
+ padding:10px 12px;border:1px solid #0a3300;border-radius:8px;
613
+ background:#000;color:inherit;cursor:pointer;text-align:left;
614
+ font:inherit;transition:border-color .15s;
615
+ }
616
+ .user-btn:hover{border-color:#33ff00;}
617
+ .avatar{
618
+ width:36px;height:36px;border-radius:50%;
619
+ background:#0a3300;color:#33ff00;font-weight:600;font-size:.875rem;
620
+ display:flex;align-items:center;justify-content:center;flex-shrink:0;
621
+ font-family:'Geist Pixel',monospace;
622
+ }
623
+ .user-text{min-width:0;}
624
+ .user-login{font-weight:600;font-size:.875rem;display:block;color:#33ff00;}
625
+ .user-meta{color:#1a8c00;font-size:.75rem;margin-top:1px;}
626
+ .user-email{font-size:.6875rem;color:#116600;word-break:break-all;margin-top:1px;}
627
+
628
+ .settings-layout{
629
+ max-width:920px;margin:0 auto;padding:28px 20px;
630
+ display:flex;gap:28px;
631
+ }
632
+ .settings-sidebar{width:200px;flex-shrink:0;}
633
+ .settings-sidebar a{
634
+ display:block;padding:6px 10px;border-radius:6px;color:#1a8c00;
635
+ text-decoration:none;font-size:.8125rem;transition:color .15s;
636
+ }
637
+ .settings-sidebar a:hover{color:#33ff00;}
638
+ .settings-sidebar a.active{color:#33ff00;font-weight:600;}
639
+ .settings-main{flex:1;min-width:0;}
640
+
641
+ .s-card{
642
+ padding:18px 0;margin-bottom:14px;border-bottom:1px solid #0a3300;
643
+ }
644
+ .s-card:last-child{border-bottom:none;}
645
+ .s-card-header{display:flex;align-items:center;gap:14px;margin-bottom:14px;}
646
+ .s-icon{
647
+ width:42px;height:42px;border-radius:8px;
648
+ background:#0a3300;display:flex;align-items:center;justify-content:center;
649
+ font-size:1.125rem;font-weight:700;color:#116600;flex-shrink:0;
650
+ font-family:'Geist Pixel',monospace;
651
+ }
652
+ .s-title{
653
+ font-family:'Geist Pixel',monospace;
654
+ font-size:1.25rem;font-weight:600;color:#33ff00;
655
+ }
656
+ .s-subtitle{font-size:.75rem;color:#1a8c00;margin-top:2px;}
657
+ .section-heading{
658
+ font-size:.9375rem;font-weight:600;margin-bottom:10px;color:#33ff00;
659
+ display:flex;align-items:center;justify-content:space-between;
660
+ }
661
+ .perm-list{list-style:none;}
662
+ .perm-list li{padding:5px 0;font-size:.8125rem;display:flex;align-items:center;gap:6px;color:#1a8c00;}
663
+ .check{color:#33ff00;}
664
+ .org-row{
665
+ display:flex;align-items:center;gap:8px;padding:7px 0;
666
+ border-bottom:1px solid #0a3300;font-size:.8125rem;
667
+ }
668
+ .org-row:last-child{border-bottom:none;}
669
+ .org-icon{
670
+ width:22px;height:22px;border-radius:4px;background:#0a3300;
671
+ display:flex;align-items:center;justify-content:center;
672
+ font-size:.625rem;font-weight:700;color:#116600;flex-shrink:0;
673
+ font-family:'Geist Pixel',monospace;
674
+ }
675
+ .org-name{font-weight:600;color:#33ff00;}
676
+ .badge{font-size:.6875rem;padding:1px 7px;border-radius:999px;font-weight:500;}
677
+ .badge-granted{background:#0a3300;color:#33ff00;}
678
+ .badge-denied{background:#1a0a0a;color:#ff4444;}
679
+ .badge-requested{background:#0a3300;color:#1a8c00;}
680
+ .btn-revoke{
681
+ display:inline-block;padding:5px 14px;border-radius:6px;
682
+ border:1px solid #0a3300;background:transparent;color:#ff4444;
683
+ font-size:.75rem;font-weight:600;cursor:pointer;transition:border-color .15s;
684
+ }
685
+ .btn-revoke:hover{border-color:#ff4444;}
686
+ .info-text{color:#1a8c00;font-size:.75rem;line-height:1.5;margin-top:10px;}
687
+ .app-link{
688
+ display:flex;align-items:center;gap:12px;padding:12px;
689
+ border:1px solid #0a3300;border-radius:8px;background:#000;
690
+ text-decoration:none;color:inherit;margin-bottom:8px;transition:border-color .15s;
691
+ }
692
+ .app-link:hover{border-color:#33ff00;}
693
+ .app-link-name{font-weight:600;font-size:.875rem;color:#33ff00;}
694
+ .app-link-scopes{font-size:.6875rem;color:#1a8c00;margin-top:1px;}
695
+ .empty{color:#1a8c00;text-align:center;padding:28px 0;font-size:.875rem;}
696
+ `;
697
+ var POWERED_BY = `<div class="powered-by">Powered by <a href="https://emulate.dev" target="_blank" rel="noopener">emulate</a></div>`;
698
+ function emuBar(service) {
699
+ const title = service ? `${escapeHtml(service)} Emulator` : "Emulator";
700
+ return `<div class="emu-bar">
701
+ <span class="emu-bar-title">${title}</span>
702
+ <nav class="emu-bar-links">
703
+ <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>
704
+ <a href="https://github.com/vercel-labs/emulate" target="_blank" rel="noopener"><span class="full">Source Code</span><span class="short">Source</span></a>
705
+ <a href="https://emulate.dev" target="_blank" rel="noopener"><span class="full">Learn More</span><span class="short">Learn</span></a>
706
+ </nav>
707
+ </div>`;
708
+ }
709
+ function head(title) {
710
+ return `<!DOCTYPE html>
711
+ <html lang="en">
712
+ <head>
713
+ <meta charset="utf-8"/>
714
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
715
+ <title>${escapeHtml(title)} | emulate</title>
716
+ <style>${CSS}</style>
717
+ </head>`;
718
+ }
719
+ function renderCardPage(title, subtitle, body, service) {
720
+ return `${head(title)}
721
+ <body>
722
+ ${emuBar(service)}
723
+ <div class="content">
724
+ <div class="content-inner">
725
+ <div class="card-title">${escapeHtml(title)}</div>
726
+ <div class="card-subtitle">${subtitle}</div>
727
+ ${body}
728
+ </div>
729
+ </div>
730
+ ${POWERED_BY}
731
+ </body></html>`;
732
+ }
733
+ function renderErrorPage(title, message, service) {
734
+ return `${head(title)}
735
+ <body>
736
+ ${emuBar(service)}
737
+ <div class="content">
738
+ <div class="content-inner error-card">
739
+ <div class="error-title">${escapeHtml(title)}</div>
740
+ <div class="error-msg">${escapeHtml(message)}</div>
741
+ </div>
742
+ </div>
743
+ ${POWERED_BY}
744
+ </body></html>`;
745
+ }
746
+ function renderSettingsPage(title, sidebarHtml, bodyHtml, service) {
747
+ return `${head(title)}
748
+ <body>
749
+ ${emuBar(service)}
750
+ <div class="settings-layout">
751
+ <nav class="settings-sidebar">${sidebarHtml}</nav>
752
+ <div class="settings-main">${bodyHtml}</div>
753
+ </div>
754
+ ${POWERED_BY}
755
+ </body></html>`;
756
+ }
757
+ function renderUserButton(opts) {
758
+ const hiddens = Object.entries(opts.hiddenFields).map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}"/>`).join("");
759
+ const nameLine = opts.name ? `<div class="user-meta">${escapeHtml(opts.name)}</div>` : "";
760
+ const emailLine = opts.email ? `<div class="user-email">${escapeHtml(opts.email)}</div>` : "";
761
+ return `<form class="user-form" method="post" action="${escapeAttr(opts.formAction)}">
762
+ ${hiddens}
763
+ <button type="submit" class="user-btn">
764
+ <span class="avatar">${escapeHtml(opts.letter)}</span>
765
+ <span class="user-text">
766
+ <span class="user-login">${escapeHtml(opts.login)}</span>
767
+ ${nameLine}${emailLine}
768
+ </span>
769
+ </button>
770
+ </form>`;
771
+ }
772
+
773
+ // src/oauth-helpers.ts
774
+ import { timingSafeEqual } from "crypto";
775
+ function normalizeUri(uri) {
776
+ try {
777
+ const u = new URL(uri);
778
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
779
+ } catch {
780
+ return uri.replace(/\/+$/, "").split("?")[0];
781
+ }
782
+ }
783
+ function matchesRedirectUri(incoming, registered) {
784
+ const normalized = normalizeUri(incoming);
785
+ return registered.some((r) => normalizeUri(r) === normalized);
786
+ }
787
+ function constantTimeSecretEqual(a, b) {
788
+ const bufA = Buffer.from(a, "utf-8");
789
+ const bufB = Buffer.from(b, "utf-8");
790
+ if (bufA.length !== bufB.length) return false;
791
+ return timingSafeEqual(bufA, bufB);
792
+ }
793
+ function bodyStr(v) {
794
+ if (typeof v === "string") return v;
795
+ if (Array.isArray(v) && typeof v[0] === "string") return v[0];
796
+ return "";
797
+ }
798
+ function parseCookies(header) {
799
+ const cookies = {};
800
+ for (const part of header.split(";")) {
801
+ const [k, ...v] = part.split("=");
802
+ if (k) cookies[k.trim()] = v.join("=").trim();
803
+ }
804
+ return cookies;
805
+ }
806
+ export {
807
+ ApiError,
808
+ Collection,
809
+ Store,
810
+ WebhookDispatcher,
811
+ authMiddleware,
812
+ bodyStr,
813
+ constantTimeSecretEqual,
814
+ createApiErrorHandler,
815
+ createErrorHandler,
816
+ createServer,
817
+ debug,
818
+ errorHandler,
819
+ escapeAttr,
820
+ escapeHtml,
821
+ forbidden,
822
+ matchesRedirectUri,
823
+ normalizeUri,
824
+ notFound,
825
+ parseCookies,
826
+ parseJsonBody,
827
+ parsePagination,
828
+ registerFontRoutes,
829
+ renderCardPage,
830
+ renderErrorPage,
831
+ renderSettingsPage,
832
+ renderUserButton,
833
+ requireAppAuth,
834
+ requireAuth,
835
+ setLinkHeader,
836
+ unauthorized,
837
+ validationError
838
+ };
839
+ //# sourceMappingURL=index.js.map