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/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
+ };
@@ -0,0 +1,3 @@
1
+ // Stub for the `ai` (Vercel AI SDK) package, which `agents` references via
2
+ // a dynamic import we don't use. This file is aliased in wrangler.toml.
3
+ export const jsonSchema = (..._args: unknown[]) => ({});
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
+ }