yorck-mcp 0.1.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 +76 -0
- package/bin/yorck.mjs +23 -0
- package/package.json +42 -0
- package/src/cli.ts +508 -0
- package/src/index.ts +408 -0
- package/src/lib/ai-stub.ts +3 -0
- package/src/lib/ics.ts +37 -0
- package/src/lib/jwt.ts +19 -0
- package/src/lib/srp.ts +248 -0
- package/src/lib/tz.ts +75 -0
- package/src/local-mcp.ts +72 -0
- package/src/mcp.ts +30 -0
- package/src/tool-registration.ts +228 -0
- package/src/types.ts +69 -0
- package/src/yorck/auth.ts +156 -0
- package/src/yorck/booking.ts +442 -0
- package/src/yorck/browserAuth.ts +360 -0
- package/src/yorck/browserBook.ts +220 -0
- package/src/yorck/buildId.ts +46 -0
- package/src/yorck/cinemas.ts +64 -0
- package/src/yorck/films.ts +249 -0
- package/src/yorck/seats.ts +169 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "./types.ts";
|
|
3
|
+
import { searchShowtimes, findFilmsByQuery, getComingSoon } from "./yorck/films.ts";
|
|
4
|
+
import { getCinemas, getCinemaBySlug } from "./yorck/cinemas.ts";
|
|
5
|
+
import { getSeatPlan, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
6
|
+
import { reserveUnlimited, cancelOrder, getOrder } from "./yorck/booking.ts";
|
|
7
|
+
import { whoAmI } from "./yorck/auth.ts";
|
|
8
|
+
import { showtimeToIcs } from "./lib/ics.ts";
|
|
9
|
+
import { PublicYorckMcp, YorckMcp } from "./mcp.ts";
|
|
10
|
+
|
|
11
|
+
export { PublicYorckMcp, YorckMcp };
|
|
12
|
+
|
|
13
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
14
|
+
|
|
15
|
+
function isAuthorized(req: Request, env: Env): boolean {
|
|
16
|
+
const expected = env.YORCK_MCP_AUTH_TOKEN;
|
|
17
|
+
if (!expected) return true;
|
|
18
|
+
|
|
19
|
+
const auth = req.headers.get("Authorization") || "";
|
|
20
|
+
const bearer = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
21
|
+
const explicit = req.headers.get("x-yorck-mcp-token") || undefined;
|
|
22
|
+
return bearer === expected || explicit === expected;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unauthorizedResponse(): Response {
|
|
26
|
+
return new Response("unauthorized", {
|
|
27
|
+
status: 401,
|
|
28
|
+
headers: { "WWW-Authenticate": "Bearer" },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requireAuth(c: { req: { raw: Request }; env: Env; text: (body: string, status?: number, headers?: Record<string, string>) => Response }) {
|
|
33
|
+
if (isAuthorized(c.req.raw, c.env)) return null;
|
|
34
|
+
return c.text("unauthorized", 401, { "WWW-Authenticate": "Bearer" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
app.get("/", (c) =>
|
|
38
|
+
c.text(
|
|
39
|
+
[
|
|
40
|
+
"yorck-mcp Worker.",
|
|
41
|
+
"Public read-only MCP: /public/mcp (Streamable HTTP), /public/sse (SSE).",
|
|
42
|
+
"Private authenticated MCP with booking: /mcp, /sse.",
|
|
43
|
+
"Public REST: /v1/films, /v1/films/search, /v1/coming-soon, /v1/cinemas, /v1/seat-plan/:sessionId, /v1/seat-map/:sessionId.",
|
|
44
|
+
"Private REST: /v1/book/*, /v1/me, /v1/browser-*.",
|
|
45
|
+
].join("\n")
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// REST endpoints (also handy for quick curl tests)
|
|
50
|
+
app.get("/v1/films", async (c) => {
|
|
51
|
+
const q = c.req.query();
|
|
52
|
+
const showtimes = await searchShowtimes(c.env, {
|
|
53
|
+
when: q.when as string | undefined,
|
|
54
|
+
date: q.date,
|
|
55
|
+
cinemas: q.cinemas?.split(","),
|
|
56
|
+
formats: q.formats?.split(","),
|
|
57
|
+
preferEnglish: q.preferEnglish === "true",
|
|
58
|
+
genres: q.genres?.split(","),
|
|
59
|
+
fskMax: q.fskMax ? parseInt(q.fskMax, 10) : undefined,
|
|
60
|
+
runtimeMax: q.runtimeMax ? parseInt(q.runtimeMax, 10) : undefined,
|
|
61
|
+
after: q.after,
|
|
62
|
+
before: q.before,
|
|
63
|
+
yorckPick: q.yorckPick === "true",
|
|
64
|
+
district: q.district?.split(","),
|
|
65
|
+
query: q.q,
|
|
66
|
+
});
|
|
67
|
+
return c.json({ count: showtimes.length, showtimes: showtimes.slice(0, 100) });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.get("/v1/films/search", async (c) => {
|
|
71
|
+
const q = c.req.query("q");
|
|
72
|
+
if (!q) return c.text("missing ?q=", 400);
|
|
73
|
+
const matches = await findFilmsByQuery(c.env, q, parseInt(c.req.query("limit") ?? "5", 10));
|
|
74
|
+
return c.json(matches);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
app.get("/v1/coming-soon", async (c) => c.json(await getComingSoon(c.env)));
|
|
78
|
+
|
|
79
|
+
app.get("/v1/cinemas", async (c) => c.json(await getCinemas(c.env)));
|
|
80
|
+
app.get("/v1/cinemas/:slug", async (c) => {
|
|
81
|
+
const cinema = await getCinemaBySlug(c.env, c.req.param("slug"));
|
|
82
|
+
if (!cinema) return c.text("not found", 404);
|
|
83
|
+
return c.json(cinema);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Seat plan as raw JSON (for diagnostics — see PhysicalName values)
|
|
87
|
+
app.get("/v1/seat-plan/:sessionId", async (c) => {
|
|
88
|
+
const sessionId = c.req.param("sessionId");
|
|
89
|
+
const [cinemaVistaId, sessionNum] = sessionId.split("-");
|
|
90
|
+
if (!cinemaVistaId || !sessionNum) return c.text("bad sessionId", 400);
|
|
91
|
+
const plan = await getSeatPlan(c.env, cinemaVistaId, sessionNum);
|
|
92
|
+
// Project to a small, readable shape.
|
|
93
|
+
const rows = (plan.SeatLayoutData.Areas[0]?.Rows ?? []).map((r) => ({
|
|
94
|
+
physicalName: r.PhysicalName,
|
|
95
|
+
rowIndex: r.RowIndexZeroBased,
|
|
96
|
+
availableIds: (r.Seats ?? []).filter((s) => s.Status === 0).map((s) => s.Id),
|
|
97
|
+
}));
|
|
98
|
+
return c.json({ rows });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Seat plan as SVG (renders directly in browser)
|
|
102
|
+
app.get("/v1/seat-map/:sessionId", async (c) => {
|
|
103
|
+
const sessionId = c.req.param("sessionId");
|
|
104
|
+
const [cinemaVistaId, sessionNum] = sessionId.split("-");
|
|
105
|
+
if (!cinemaVistaId || !sessionNum) return c.text("bad sessionId", 400);
|
|
106
|
+
const cinemas = await getCinemas(c.env);
|
|
107
|
+
const cinema = cinemas.find((x) => x.vistaId === cinemaVistaId);
|
|
108
|
+
if (!cinema) return c.text("unknown cinema", 404);
|
|
109
|
+
const plan = await getSeatPlan(c.env, cinemaVistaId, sessionNum);
|
|
110
|
+
const svg = renderSeatPlanSvg(plan, {
|
|
111
|
+
title: cinema.name,
|
|
112
|
+
subtitle: `Session ${sessionId}`,
|
|
113
|
+
});
|
|
114
|
+
return new Response(svg, { headers: { "Content-Type": "image/svg+xml" } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Public downloadable calendar file. This makes the read-only MCP useful even
|
|
118
|
+
// for clients that cannot write files themselves: the agent can hand the user a
|
|
119
|
+
// normal .ics URL that Apple Calendar, Google Calendar, and Outlook can import.
|
|
120
|
+
app.get("/v1/calendar/:filename", async (c) => {
|
|
121
|
+
const sessionId = c.req.param("filename").replace(/\.ics$/i, "");
|
|
122
|
+
const slug = c.req.query("slug");
|
|
123
|
+
const all = await searchShowtimes(c.env, { cinemas: undefined, formats: ["OmeU", "OV", "OmU", "DF"] });
|
|
124
|
+
const showtime = all.find((x) => x.sessionId === sessionId && (!slug || x.slug === slug));
|
|
125
|
+
if (!showtime) return c.text("session not found", 404);
|
|
126
|
+
const cinemas = await getCinemas(c.env);
|
|
127
|
+
const cinema = cinemas.find((x) => x.slug === showtime.cinemaSlug);
|
|
128
|
+
const ics = showtimeToIcs(showtime, cinema?.address);
|
|
129
|
+
const filename = `${showtime.slug}-${sessionId}.ics`.replace(/[^a-z0-9._-]+/gi, "-");
|
|
130
|
+
return new Response(ics, {
|
|
131
|
+
headers: {
|
|
132
|
+
"Content-Type": "text/calendar; charset=utf-8",
|
|
133
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
134
|
+
"Cache-Control": "public, max-age=300",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Step-by-step diagnostic — runs each booking call separately so we can see
|
|
140
|
+
// which one actually fails. Returns the response of every step.
|
|
141
|
+
app.post("/v1/book/debug", async (c) => {
|
|
142
|
+
const authError = requireAuth(c);
|
|
143
|
+
if (authError) return authError;
|
|
144
|
+
const body = await c.req.json<{ sessionId: string; cinemaSlug: string; rowLabel: string; seatId: string }>();
|
|
145
|
+
const out: Array<{ step: string; ok: boolean; data?: unknown; error?: string }> = [];
|
|
146
|
+
try {
|
|
147
|
+
const { authHeaders } = await import("./yorck/auth.ts");
|
|
148
|
+
const { getCinemaBySlug } = await import("./yorck/cinemas.ts");
|
|
149
|
+
const { getSeatPlan, findSeatByRowAndId } = await import("./yorck/seats.ts");
|
|
150
|
+
const auth = await authHeaders(c.env);
|
|
151
|
+
|
|
152
|
+
const cinema = await getCinemaBySlug(c.env, body.cinemaSlug);
|
|
153
|
+
if (!cinema) return c.json({ ok: false, error: "unknown cinema" }, 400);
|
|
154
|
+
const [, sessionNum] = body.sessionId.split("-");
|
|
155
|
+
|
|
156
|
+
const baseHeaders = {
|
|
157
|
+
Accept: "application/json",
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
Origin: "https://www.yorck.de",
|
|
160
|
+
Referer: "https://www.yorck.de/",
|
|
161
|
+
"User-Agent": "Mozilla/5.0 (yorck-mcp)",
|
|
162
|
+
...auth,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const VISTA = c.env.VISTA_BASE;
|
|
166
|
+
|
|
167
|
+
// 1. Create order
|
|
168
|
+
const r1 = await fetch(VISTA + "/orders", { method: "POST", headers: baseHeaders, body: JSON.stringify({ cinemaId: cinema.vistaId }) });
|
|
169
|
+
const t1 = await r1.text();
|
|
170
|
+
out.push({ step: "createOrder", ok: r1.ok, data: { status: r1.status, body: t1.slice(0, 500) } });
|
|
171
|
+
if (!r1.ok) return c.json({ ok: false, steps: out }, 500);
|
|
172
|
+
const usid = JSON.parse(t1).order.userSessionId;
|
|
173
|
+
|
|
174
|
+
// 2. Get tickets list
|
|
175
|
+
const r2 = await fetch(VISTA + `/RESTData.svc/cinemas/${cinema.vistaId}/sessions/${sessionNum}/tickets?salesChannelFilter=WWW&userSessionId=${usid}`, { headers: baseHeaders });
|
|
176
|
+
const t2 = await r2.text();
|
|
177
|
+
out.push({ step: "tickets", ok: r2.ok, data: { status: r2.status, body: t2.slice(0, 500) } });
|
|
178
|
+
if (!r2.ok) return c.json({ ok: false, userSessionId: usid, steps: out }, 500);
|
|
179
|
+
|
|
180
|
+
// 3. Resolve seat
|
|
181
|
+
const plan = await getSeatPlan(c.env, cinema.vistaId, sessionNum);
|
|
182
|
+
const seat = findSeatByRowAndId(plan, body.rowLabel, body.seatId);
|
|
183
|
+
if (!seat) {
|
|
184
|
+
out.push({ step: "seat", ok: false, error: "seat not found" });
|
|
185
|
+
return c.json({ ok: false, steps: out }, 400);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 4. Set tickets
|
|
189
|
+
const tickets = JSON.parse(t2).Tickets as any[];
|
|
190
|
+
const std = tickets.find((t) => t.Description === "Normal (Online)") ?? tickets[0];
|
|
191
|
+
const setBody = {
|
|
192
|
+
tickets: [{
|
|
193
|
+
ticketDetails: {
|
|
194
|
+
ticketTypeCode: std.TicketTypeCode,
|
|
195
|
+
ticketCode: std.TicketCode,
|
|
196
|
+
areaCategoryCode: std.AreaCategoryCode,
|
|
197
|
+
headOfficeGroupingCode: std.HeadOfficeGroupingCode,
|
|
198
|
+
priceInCents: std.PriceInCents,
|
|
199
|
+
},
|
|
200
|
+
seats: [{ areaNumber: seat.Position.AreaNumber, rowIndex: seat.Position.RowIndex, columnIndex: seat.Position.ColumnIndex }],
|
|
201
|
+
}],
|
|
202
|
+
};
|
|
203
|
+
const r3 = await fetch(VISTA + `/orders/${usid}/sessions/${sessionNum}/set-tickets`, { method: "POST", headers: baseHeaders, body: JSON.stringify(setBody) });
|
|
204
|
+
const t3 = await r3.text();
|
|
205
|
+
out.push({ step: "setTickets", ok: r3.ok, data: { status: r3.status, body: t3.slice(0, 500) } });
|
|
206
|
+
|
|
207
|
+
// 4.5a. Set customer details (the React app does this before validate).
|
|
208
|
+
const { whoAmI } = await import("./yorck/auth.ts");
|
|
209
|
+
const me = await whoAmI(c.env);
|
|
210
|
+
const [first = "Yorck", ...rest] = (me.name || "Member").split(" ");
|
|
211
|
+
const cdBody = { firstName: first, lastName: rest.join(" ") || "Member", email: me.email };
|
|
212
|
+
const r34 = await fetch(VISTA + `/orders/${usid}/customer-details`, { method: "POST", headers: baseHeaders, body: JSON.stringify(cdBody) });
|
|
213
|
+
const t34 = await r34.text();
|
|
214
|
+
out.push({ step: "customerDetails", ok: r34.ok, data: { status: r34.status, body: t34.slice(0, 300) } });
|
|
215
|
+
|
|
216
|
+
// 4.5b. Validate the member to get a LoyaltySessionToken.
|
|
217
|
+
const memberBody = {
|
|
218
|
+
UserSessionId: me.memberId + Date.now(),
|
|
219
|
+
MemberId: me.memberId,
|
|
220
|
+
ReturnMember: true,
|
|
221
|
+
};
|
|
222
|
+
const r35 = await fetch(VISTA + "/RESTLoyalty.svc/member/validate", { method: "POST", headers: baseHeaders, body: JSON.stringify(memberBody) });
|
|
223
|
+
const t35 = await r35.text();
|
|
224
|
+
out.push({ step: "validateMember", ok: r35.ok, data: { status: r35.status, body: t35.slice(0, 4000), reqBody: memberBody } });
|
|
225
|
+
|
|
226
|
+
// 4.6. Pull the LoyaltySessionToken out of the validateMember response.
|
|
227
|
+
let loyaltySessionToken: string | undefined;
|
|
228
|
+
try {
|
|
229
|
+
const memberJson = JSON.parse(t35);
|
|
230
|
+
loyaltySessionToken = memberJson.LoyaltySessionToken;
|
|
231
|
+
} catch {}
|
|
232
|
+
|
|
233
|
+
// 5. Validate (try several variations, with and without the loyaltySessionToken header).
|
|
234
|
+
// Pull the embedded vistaAccessToken2 from the id-token claim — this is
|
|
235
|
+
// what the gateway used to validate against, the original "loyaltySessionToken".
|
|
236
|
+
const { decodeJwt } = await import("./lib/jwt.ts");
|
|
237
|
+
const idClaims = decodeJwt<{ "custom:vistaAccessToken2"?: string }>(auth["id-token"]);
|
|
238
|
+
const embeddedVistaToken = idClaims["custom:vistaAccessToken2"] ?? "";
|
|
239
|
+
|
|
240
|
+
const lst = loyaltySessionToken ?? "";
|
|
241
|
+
const variants: Array<{ name: string; body: any; extraHeaders?: Record<string, string> }> = [
|
|
242
|
+
{ name: "card-no-extra", body: { UserSessionId: usid, CinemaId: cinema.vistaId, SessionId: parseInt(sessionNum, 10), TicketTypes: [{ TicketTypeCode: "0183", Qty: 1, ThirdPartyMemberScheme: { MemberCard: c.env.YORCK_UNLIMITED_CARD } }] } },
|
|
243
|
+
{ name: "embedded-as-lst-header", body: { UserSessionId: usid, CinemaId: cinema.vistaId, SessionId: parseInt(sessionNum, 10), TicketTypes: [{ TicketTypeCode: "0183", Qty: 1, ThirdPartyMemberScheme: { MemberCard: c.env.YORCK_UNLIMITED_CARD } }] }, extraHeaders: { loyaltySessionToken: embeddedVistaToken } },
|
|
244
|
+
{ name: "embedded-and-validatemember-lst", body: { UserSessionId: usid, CinemaId: cinema.vistaId, SessionId: parseInt(sessionNum, 10), TicketTypes: [{ TicketTypeCode: "0183", Qty: 1, ThirdPartyMemberScheme: { MemberCard: c.env.YORCK_UNLIMITED_CARD } }] }, extraHeaders: { loyaltySessionToken: embeddedVistaToken, "x-loyalty-session-token": lst } },
|
|
245
|
+
{ name: "no-loyaltytoken-but-with-uppercase-membercardnumber", body: { UserSessionId: usid, CinemaId: cinema.vistaId, SessionId: parseInt(sessionNum, 10), TicketTypes: [{ TicketTypeCode: "0183", Qty: 1, ThirdPartyMemberScheme: { MemberCardNumber: c.env.YORCK_UNLIMITED_CARD } }] } },
|
|
246
|
+
];
|
|
247
|
+
for (const variant of variants) {
|
|
248
|
+
const headers: Record<string, string> = { ...baseHeaders, ...(variant.extraHeaders || {}) };
|
|
249
|
+
const r = await fetch(VISTA + "/RESTTicketing.svc/order/validate/membertickets", { method: "POST", headers, body: JSON.stringify(variant.body) });
|
|
250
|
+
const t = await r.text();
|
|
251
|
+
out.push({ step: `validate:${variant.name}`, ok: r.ok, data: { status: r.status, body: t.slice(0, 800) } });
|
|
252
|
+
if (r.ok && t.includes("\"Result\":0")) break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Also try alternative endpoints — Vista sometimes does the Unlimited swap
|
|
256
|
+
// via concessions / loyalty-redemption / set-member-ticket.
|
|
257
|
+
for (const altPath of [
|
|
258
|
+
"/RESTTicketing.svc/order/concessions",
|
|
259
|
+
"/RESTTicketing.svc/order/loyalty-redemption",
|
|
260
|
+
`/orders/${usid}/sessions/${sessionNum}/set-member-tickets`,
|
|
261
|
+
`/orders/${usid}/sessions/${sessionNum}/redeem-member-ticket`,
|
|
262
|
+
]) {
|
|
263
|
+
const r = await fetch(VISTA + altPath, { method: "POST", headers: baseHeaders, body: JSON.stringify({ UserSessionId: usid, MemberCard: c.env.YORCK_UNLIMITED_CARD, TicketTypeCode: "0183" }) });
|
|
264
|
+
const t = await r.text();
|
|
265
|
+
out.push({ step: `alt:${altPath}`, ok: r.ok, data: { status: r.status, body: t.slice(0, 400) } });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 6. Cancel
|
|
269
|
+
const r5 = await fetch(VISTA + "/RESTTicketing.svc/order/cancel", { method: "POST", headers: baseHeaders, body: JSON.stringify({ UserSessionId: usid }) });
|
|
270
|
+
const t5 = await r5.text();
|
|
271
|
+
out.push({ step: "cancel", ok: r5.ok, data: { status: r5.status, body: t5.slice(0, 300) } });
|
|
272
|
+
|
|
273
|
+
return c.json({ ok: true, userSessionId: usid, steps: out, authHeadersUsed: Object.keys(auth) });
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return c.json({ ok: false, error: String(e), steps: out }, 500);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Booking endpoints (POST so they're not idempotent-by-accident)
|
|
280
|
+
app.post("/v1/book/preview", async (c) => {
|
|
281
|
+
const authError = requireAuth(c);
|
|
282
|
+
if (authError) return authError;
|
|
283
|
+
const body = await c.req.json<{ sessionId: string; cinemaSlug: string; rowLabel: string; seatId: string }>();
|
|
284
|
+
try {
|
|
285
|
+
const r = await reserveUnlimited(c.env, body);
|
|
286
|
+
// Preview = release seat immediately
|
|
287
|
+
await cancelOrder(c.env, r.order.userSessionId);
|
|
288
|
+
return c.json({
|
|
289
|
+
approved: r.approved,
|
|
290
|
+
ticketTypeCode: r.ticketTypeCode,
|
|
291
|
+
orderTotal: r.order.orderTotalValueInCents / 100,
|
|
292
|
+
expiresAt: r.expiresAt,
|
|
293
|
+
seat: { row: body.rowLabel, seat: body.seatId },
|
|
294
|
+
note: "preview only, hold released",
|
|
295
|
+
});
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return c.json({ ok: false, error: String(e), stack: (e as Error).stack?.split("\n").slice(0, 5) }, 500);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
app.post("/v1/book/commit", async (c) => {
|
|
302
|
+
const authError = requireAuth(c);
|
|
303
|
+
if (authError) return authError;
|
|
304
|
+
const body = await c.req.json<{ sessionId: string; cinemaSlug: string; rowLabel: string; seatId: string }>();
|
|
305
|
+
const r = await reserveUnlimited(c.env, body);
|
|
306
|
+
if (!r.approved) {
|
|
307
|
+
await cancelOrder(c.env, r.order.userSessionId);
|
|
308
|
+
return c.json({ ok: false, error: "Unlimited validation did not approve" }, 400);
|
|
309
|
+
}
|
|
310
|
+
const { commitOrder } = await import("./yorck/booking.ts");
|
|
311
|
+
const result = await commitOrder(c.env, r.order.userSessionId);
|
|
312
|
+
return c.json({
|
|
313
|
+
ok: true,
|
|
314
|
+
orderTotal: r.order.orderTotalValueInCents / 100,
|
|
315
|
+
seat: { row: body.rowLabel, seat: body.seatId },
|
|
316
|
+
cinema: body.cinemaSlug,
|
|
317
|
+
sessionId: body.sessionId,
|
|
318
|
+
commit: result,
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
app.get("/v1/me", async (c) => {
|
|
323
|
+
const authError = requireAuth(c);
|
|
324
|
+
if (authError) return authError;
|
|
325
|
+
try {
|
|
326
|
+
return c.json(await whoAmI(c.env));
|
|
327
|
+
} catch (e) {
|
|
328
|
+
return c.json({ error: String(e) }, 500);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Diagnostic — log into yorck.de in a real browser and, while logged in,
|
|
333
|
+
// fire validate-membertickets from inside the page context (so the React
|
|
334
|
+
// app's auth interceptor adds whatever it adds). Returns the response
|
|
335
|
+
// verbatim so we can see what's actually expected.
|
|
336
|
+
app.get("/v1/browser-validate", async (c) => {
|
|
337
|
+
const authError = requireAuth(c);
|
|
338
|
+
if (authError) return authError;
|
|
339
|
+
try {
|
|
340
|
+
const sessionId = c.req.query("sessionId") || "1009-5990";
|
|
341
|
+
const cinemaSlug = c.req.query("cinemaSlug") || "kino-international";
|
|
342
|
+
const rowLabel = c.req.query("rowLabel") || "16";
|
|
343
|
+
const seatId = c.req.query("seatId") || "30";
|
|
344
|
+
const { runValidateInBrowser } = await import("./yorck/browserBook.ts");
|
|
345
|
+
const r = await runValidateInBrowser(c.env, { sessionId, cinemaSlug, rowLabel, seatId });
|
|
346
|
+
return c.json(r);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return c.json({ ok: false, error: String(e), stack: (e as Error).stack?.split("\n").slice(0, 10) }, 500);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Diagnostic — drives a real browser login on yorck.de and returns what tokens
|
|
353
|
+
// + (if observed) what validate-call headers the React app actually sends.
|
|
354
|
+
// Useful for comparing against our direct-fetch path.
|
|
355
|
+
app.get("/v1/browser-tokens", async (c) => {
|
|
356
|
+
const authError = requireAuth(c);
|
|
357
|
+
if (authError) return authError;
|
|
358
|
+
try {
|
|
359
|
+
const { captureTokensViaBrowser } = await import("./yorck/browserAuth.ts");
|
|
360
|
+
const t = await captureTokensViaBrowser(c.env, { force: c.req.query("force") === "true" });
|
|
361
|
+
return c.json({
|
|
362
|
+
ok: true,
|
|
363
|
+
idTokenHead: t.idToken.slice(0, 32) + "…",
|
|
364
|
+
loyaltyAccessTokenHead: t.loyaltyAccessToken ? t.loyaltyAccessToken.slice(0, 32) + "…" : null,
|
|
365
|
+
observedValidateUrl: t.observedValidateUrl,
|
|
366
|
+
observedValidateHeaders: t.observedValidateHeaders,
|
|
367
|
+
observedValidateBody: t.observedValidateBody,
|
|
368
|
+
cookieCount: (t.cookies.match(/=/g) ?? []).length,
|
|
369
|
+
capturedAtMs: t.capturedAtMs,
|
|
370
|
+
diag: t._diag,
|
|
371
|
+
});
|
|
372
|
+
} catch (e) {
|
|
373
|
+
return c.json({ ok: false, error: String(e), stack: (e as Error).stack?.split("\n").slice(0, 8) }, 500);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Delegate MCP routes before falling through to Hono routes.
|
|
378
|
+
const publicCors = {
|
|
379
|
+
origin: "*",
|
|
380
|
+
methods: "GET, POST, DELETE, OPTIONS",
|
|
381
|
+
headers: "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version",
|
|
382
|
+
exposeHeaders: "Mcp-Session-Id",
|
|
383
|
+
};
|
|
384
|
+
const publicMcpStreamable = PublicYorckMcp.serve("/public/mcp", { binding: "PUBLIC_MCP_OBJECT", corsOptions: publicCors });
|
|
385
|
+
const publicMcpSse = PublicYorckMcp.serveSSE("/public/sse", { binding: "PUBLIC_MCP_OBJECT", corsOptions: publicCors });
|
|
386
|
+
const mcpStreamable = YorckMcp.serve("/mcp");
|
|
387
|
+
const mcpSse = YorckMcp.serveSSE("/sse");
|
|
388
|
+
|
|
389
|
+
export default {
|
|
390
|
+
fetch(req: Request, env: Env, ctx: ExecutionContext): Response | Promise<Response> {
|
|
391
|
+
const url = new URL(req.url);
|
|
392
|
+
if (url.pathname === "/public/mcp" || url.pathname.startsWith("/public/mcp/")) {
|
|
393
|
+
return publicMcpStreamable.fetch(req, env, ctx);
|
|
394
|
+
}
|
|
395
|
+
if (url.pathname === "/public/sse" || url.pathname.startsWith("/public/sse/")) {
|
|
396
|
+
return publicMcpSse.fetch(req, env, ctx);
|
|
397
|
+
}
|
|
398
|
+
if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) {
|
|
399
|
+
if (!isAuthorized(req, env)) return unauthorizedResponse();
|
|
400
|
+
return mcpStreamable.fetch(req, env, ctx);
|
|
401
|
+
}
|
|
402
|
+
if (url.pathname === "/sse" || url.pathname.startsWith("/sse/")) {
|
|
403
|
+
if (!isAuthorized(req, env)) return unauthorizedResponse();
|
|
404
|
+
return mcpSse.fetch(req, env, ctx);
|
|
405
|
+
}
|
|
406
|
+
return app.fetch(req, env, ctx);
|
|
407
|
+
},
|
|
408
|
+
};
|
package/src/lib/ics.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Showtime } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
function pad(n: number) {
|
|
4
|
+
return String(n).padStart(2, "0");
|
|
5
|
+
}
|
|
6
|
+
function toIcsUtc(iso: string): string {
|
|
7
|
+
const d = new Date(iso);
|
|
8
|
+
return (
|
|
9
|
+
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
|
10
|
+
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
function escape(s: string): string {
|
|
14
|
+
return s.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function showtimeToIcs(s: Showtime, address?: string): string {
|
|
18
|
+
const uid = `yorck-${s.sessionId}@yorck-mcp`;
|
|
19
|
+
const lines = [
|
|
20
|
+
"BEGIN:VCALENDAR",
|
|
21
|
+
"VERSION:2.0",
|
|
22
|
+
"PRODID:-//yorck-mcp//EN",
|
|
23
|
+
"CALSCALE:GREGORIAN",
|
|
24
|
+
"BEGIN:VEVENT",
|
|
25
|
+
`UID:${uid}`,
|
|
26
|
+
`DTSTAMP:${toIcsUtc(new Date().toISOString())}`,
|
|
27
|
+
`DTSTART:${toIcsUtc(s.start)}`,
|
|
28
|
+
`DTEND:${toIcsUtc(s.end)}`,
|
|
29
|
+
`SUMMARY:${escape(s.film + " — " + s.cinema)}`,
|
|
30
|
+
`LOCATION:${escape(address || s.cinema)}`,
|
|
31
|
+
`URL:${s.url}`,
|
|
32
|
+
`DESCRIPTION:${escape(`${s.film} (${s.format})\\nRuntime: ${s.runtime} min\\nBook: ${s.url}`)}`,
|
|
33
|
+
"END:VEVENT",
|
|
34
|
+
"END:VCALENDAR",
|
|
35
|
+
];
|
|
36
|
+
return lines.join("\r\n");
|
|
37
|
+
}
|
package/src/lib/jwt.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function decodeJwt<T = Record<string, unknown>>(token: string): T {
|
|
2
|
+
const parts = token.split(".");
|
|
3
|
+
if (parts.length < 2) throw new Error("invalid jwt");
|
|
4
|
+
const payload = parts[1];
|
|
5
|
+
// base64url -> base64
|
|
6
|
+
const b64 = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
|
|
7
|
+
const json = atob(b64);
|
|
8
|
+
return JSON.parse(json) as T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function jwtExpired(token: string, skewSec = 30): boolean {
|
|
12
|
+
try {
|
|
13
|
+
const p = decodeJwt<{ exp?: number }>(token);
|
|
14
|
+
if (!p.exp) return true;
|
|
15
|
+
return p.exp - skewSec <= Math.floor(Date.now() / 1000);
|
|
16
|
+
} catch {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|