/** * Apple Wallet passes (.pkpass) attached to booking mail: the same cards as * schema.org markup would give. A boarding pass becomes a flight, train or * bus booking and an event ticket an event booking; every other pass is * ignored. Only the pass's `semantics` dictionary counts, as that is the * machine-readable part (Apple's "SemanticTags"); the display fields are * free text for people. A pass that leaves anything in doubt gives no card. * * The signature is not checked (that needs Apple's certificate chain); the * mail-level trust gate stands in for it. Nothing in the pass is fetched: * webServiceURL, images and links are never read. * * Plain TypeScript, no React; jszip is imported only when a pass is opened. */ import type JSZip from 'jszip'; import { cardKey } from './normalize'; import { isValidTimeZone, wallClockAt, zoneOffsetAt } from './time-zones'; import type { EventBooking, Moment, Place, Seat, StructuredCard, TransportBooking, TransportKind, TransportLeg } from './types'; import { validateCard } from './validate'; import { isObj, moment, str, type Obj } from './values'; /** Largest attachment that is downloaded and opened. */ export const PKPASS_MAX_BYTES = 5 * 1024 * 1024; /** At most this many passes are read from one mail. */ export const PKPASS_MAX_PER_MAIL = 3; /** Largest pass.json read, once inflated. */ export const PASS_JSON_MAX_BYTES = 256 * 1024; /** Archives with more entries are refused unread. */ export const PKPASS_MAX_ENTRIES = 100; const PKPASS_TYPE = 'application/vnd.apple.pkpass'; export interface PkpassAttachmentLike { name?: string | null; type?: string | null; size?: number | null; } /** An attachment that may be a Wallet pass: `*.pkpass` or typed as one, at most 5 MB. */ export function isPkpassAttachment(att: PkpassAttachmentLike | null | undefined): boolean { if (!att) return false; const size = att.size; if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0 || size > PKPASS_MAX_BYTES) return false; const byName = typeof att.name === 'string' && /\.pkpass$/i.test(att.name.trim()); const byType = typeof att.type === 'string' && att.type.split(';')[0].trim().toLowerCase() === PKPASS_TYPE; return byName || byType; } export interface ReadPassOptions { /** jszip's constructor; defaults to a dynamic import of jszip. */ loadZip?: () => Promise; } async function defaultLoadZip(): Promise { const { default: JSZipClass } = await import('jszip'); return JSZipClass; } interface ZipStream { on(event: 'data', cb: (chunk: Uint8Array) => void): ZipStream; on(event: 'error', cb: (err: Error) => void): ZipStream; on(event: 'end', cb: () => void): ZipStream; pause(): ZipStream; resume(): ZipStream; } /** * Inflate one entry, giving up as soon as it passes `maxBytes`. The sizes in * the ZIP headers are whatever the sender wrote; counting inflated bytes is * what stops a ZIP bomb. */ function readCapped(entry: JSZip.JSZipObject, maxBytes: number): Promise { return new Promise((resolve) => { const chunks: Uint8Array[] = []; let total = 0; let done = false; const finish = (value: Uint8Array | undefined) => { if (done) return; done = true; resolve(value); }; let stream: ZipStream; try { // internalStream is part of jszip's API but missing from its typings. stream = (entry as unknown as { internalStream(type: 'uint8array'): ZipStream }).internalStream('uint8array'); } catch { finish(undefined); return; } stream .on('data', (chunk) => { if (done) return; total += chunk.byteLength; if (total > maxBytes) { stream.pause(); finish(undefined); return; } chunks.push(chunk); }) .on('error', () => finish(undefined)) .on('end', () => { const out = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.byteLength; } finish(out); }) .resume(); }); } /** * The parsed pass.json of a .pkpass archive, or undefined for anything * doubtful: not a ZIP, more than 100 entries, no pass.json, pass.json over * 256 KB once inflated, not JSON, not an object. Nothing else in the archive * is inflated. Never throws. */ export async function readPassJson(bytes: ArrayBuffer | Uint8Array, options: ReadPassOptions = {}): Promise { try { if (bytes.byteLength === 0 || bytes.byteLength > PKPASS_MAX_BYTES) return undefined; const JSZipClass = await (options.loadZip ?? defaultLoadZip)(); const zip = await JSZipClass.loadAsync(bytes); if (Object.keys(zip.files).length > PKPASS_MAX_ENTRIES) return undefined; const entry = zip.file('pass.json'); if (!entry || entry.dir) return undefined; const raw = await readCapped(entry, PASS_JSON_MAX_BYTES); if (!raw) return undefined; let text = new TextDecoder('utf-8', { fatal: true }).decode(raw); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const parsed: unknown = JSON.parse(text); return isObj(parsed) ? parsed : undefined; } catch { return undefined; } } const PASS_STYLES = ['boardingPass', 'coupon', 'eventTicket', 'generic', 'storeCard'] as const; const TRANSIT_KINDS: Record = { PKTransitTypeAir: 'flight', PKTransitTypeTrain: 'train', PKTransitTypeBus: 'bus', }; /** Thrown inside the mapping when a value is present but can't be trusted. */ class Doubtful extends Error {} function doubt(): never { throw new Doubtful(); } /** A string tag: absent → undefined, present but not text → doubtful. */ function text(semantics: Obj, key: string): string | undefined { const value = semantics[key]; if (value === undefined || value === null) return undefined; if (typeof value !== 'string' && typeof value !== 'number') doubt(); return str(value); } /** A date tag: absent → undefined, present but not ISO 8601 → doubtful. */ function date(semantics: Obj, key: string): Moment | undefined { const value = semantics[key]; if (value === undefined || value === null || value === '') return undefined; return moment(value) ?? doubt(); } /** * A time as local to its place. Passes often give times in UTC (`Z`, * `+00:00`), which fixes the instant but not the wall clock. With the pass's * IANA zone for the place (departureLocationTimeZone, * destinationLocationTimeZone) the instant is turned into that zone's wall * clock; for a flight without one, the moment is marked `instantOnly` and the * airport's zone supplies the wall clock later (resolve.ts); for anything * else it is doubtful. A zone given next to a non-UTC offset has to agree * with it, otherwise one of the two is wrong. */ function placeTime(semantics: Obj, zoneKey: string | undefined, at: Moment | undefined, airport: boolean): Moment | undefined { const tag = zoneKey ? semantics[zoneKey] : undefined; let zone: string | undefined; if (tag !== undefined && tag !== null) { if (typeof tag !== 'string' || !isValidTimeZone(tag)) doubt(); zone = tag; } if (!at?.utc || at.offsetMinutes === undefined) return at; const ms = Date.parse(at.utc); if (at.offsetMinutes === 0) { if (zone) { const local = wallClockAt(ms, zone); return { date: local.date, time: local.time, offsetMinutes: zoneOffsetAt(zone, ms), utc: at.utc }; } if (airport) return { ...at, instantOnly: true }; doubt(); } if (zone && zoneOffsetAt(zone, ms) !== at.offsetMinutes) doubt(); return at; } function sameTime(a: Moment, b: Moment): boolean { if (a.utc && b.utc) return a.utc === b.utc; return a.date === b.date && (a.time ?? '') === (b.time ?? ''); } /** * transitStatus is free text. Only a status that clearly says the trip runs * as planned keeps the card; "Delayed 40 min", "Diverted", "fällt aus" or * anything unknown leaves the times in doubt. */ const PLANNED_STATUS = new Set(['on time', 'pünktlich', 'boarding', 'scheduled', 'planmäßig']); function plannedStatus(status: string | undefined): boolean { if (status === undefined) return true; const normal = status.normalize('NFC').toLowerCase().replace(/[-_]+/g, ' ').replace(/[.!]+$/, '').replace(/\s+/g, ' ').trim(); return normal === '' || PLANNED_STATUS.has(normal); } /** PersonNameComponents → "Given Family". */ function personName(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; if (!isObj(value)) doubt(); const parts = [str(value.givenName), str(value.middleName), str(value.familyName)].filter((p): p is string => !!p); return parts.length > 0 ? parts.join(' ') : undefined; } /** Split the IATA flight: airlineCode + flightNumber, flightCode ("LH400"), or both when they agree. */ function flightIdentity(semantics: Obj): { carrierCode: string; number: string } { const airline = text(semantics, 'airlineCode')?.toUpperCase(); if (airline !== undefined && !/^[A-Z0-9]{2}$/.test(airline)) doubt(); const numberRaw = text(semantics, 'flightNumber'); if (numberRaw !== undefined && !/^\d{1,4}$/.test(numberRaw)) doubt(); const number = numberRaw !== undefined ? String(Number(numberRaw)) : undefined; const codeRaw = text(semantics, 'flightCode')?.replace(/\s+/g, '').toUpperCase(); let fromCode: { carrierCode: string; number: string } | undefined; if (codeRaw !== undefined) { const match = /^([A-Z0-9]{2})(\d{1,4})$/.exec(codeRaw); if (!match || !/[A-Z]/.test(match[1])) doubt(); fromCode = { carrierCode: match[1], number: String(Number(match[2])) }; } const carrierCode = airline ?? fromCode?.carrierCode; const flight = number ?? fromCode?.number; if (!carrierCode || !flight) doubt(); if (fromCode && (fromCode.carrierCode !== carrierCode || fromCode.number !== flight)) doubt(); return { carrierCode, number: flight }; } function airport(semantics: Obj, prefix: 'departure' | 'destination', codeRequired: boolean): Place { const code = text(semantics, `${prefix}AirportCode`)?.toUpperCase(); if (code !== undefined && !/^[A-Z]{3}$/.test(code)) doubt(); if (codeRequired && !code) doubt(); const name = text(semantics, `${prefix}AirportName`); return { ...(name ? { name } : {}), ...(code ? { code } : {}) }; } function station(semantics: Obj, prefix: 'departure' | 'destination'): Place { const name = text(semantics, `${prefix}StationName`) ?? text(semantics, `${prefix}LocationDescription`); return name ? { name } : {}; } /** Seats as the markup writes them: "12A" for flights, "section/number" otherwise. */ function seats(semantics: Obj, kind: TransportKind, passenger: string | undefined): Seat[] { const value = semantics.seats; if (value === undefined || value === null) return []; if (!Array.isArray(value)) doubt(); const car = kind === 'train' ? text(semantics, 'carNumber') : undefined; const out: Seat[] = []; for (const entry of value) { if (!isObj(entry)) doubt(); const number = str(entry.seatNumber); const row = str(entry.seatRow); const identifier = str(entry.seatIdentifier); let seat: string | undefined; if (kind === 'flight') { if (number && row) { if (number.toUpperCase().startsWith(row.toUpperCase())) seat = number.toUpperCase(); else if (/^\d{1,3}$/.test(row) && /^[A-Z]$/i.test(number)) seat = `${row}${number.toUpperCase()}`; // Any other combination is left out rather than guessed. } else { seat = (number ?? identifier)?.toUpperCase(); } } else { const base = number ?? identifier; const section = str(entry.seatSection) ?? car; seat = base && section ? `${section}/${base}` : base; } if (seat) out.push({ ...(passenger ? { passenger } : {}), seat }); } return out; } function transportCard(kind: TransportKind, pass: Obj, semantics: Obj): TransportBooking { if (!plannedStatus(text(semantics, 'transitStatus'))) doubt(); const flight = kind === 'flight'; const departureTime = (key: string) => placeTime(semantics, 'departureLocationTimeZone', date(semantics, key), flight); const arrivalTime = (key: string) => placeTime(semantics, 'destinationLocationTimeZone', date(semantics, key), flight); const original = departureTime('originalDepartureDate'); const current = departureTime('currentDepartureDate'); // A departure moved to another day would make the pass describe another leg. if (original && current && original.date !== current.date) doubt(); const departureAt = current ?? original; if (!departureAt) doubt(); // The scheduled arrival belongs to the scheduled departure only; after a // change, only the updated arrival (if any) goes with the updated departure. const onSchedule = !current || (!!original && sameTime(current, original)); const arrivalAt = arrivalTime('currentArrivalDate') ?? (onSchedule ? arrivalTime('originalArrivalDate') : undefined); const passenger = personName(semantics.passengerName); const provider = text(semantics, 'transitProvider'); const organization = str(pass.organizationName); const reference = text(semantics, 'confirmationNumber'); let leg: TransportLeg; if (kind === 'flight') { const { carrierCode, number } = flightIdentity(semantics); leg = { ...(provider ? { carrier: provider } : {}), carrierCode, number, departure: { place: airport(semantics, 'departure', true), at: departureAt }, arrival: { place: airport(semantics, 'destination', false), ...(arrivalAt ? { at: arrivalAt } : {}) }, seats: seats(semantics, kind, passenger), }; } else { const number = text(semantics, 'vehicleNumber'); leg = { ...(provider ? { carrier: provider } : {}), ...(number ? { number } : {}), departure: { place: station(semantics, 'departure'), at: departureAt }, arrival: { place: station(semantics, 'destination'), ...(arrivalAt ? { at: arrivalAt } : {}) }, seats: seats(semantics, kind, passenger), }; } const shownProvider = provider ?? organization; return { key: '', kind, ...(reference ? { reference } : {}), passengers: passenger ? [passenger] : [], ...(shownProvider ? { provider: shownProvider } : {}), links: {}, source: 'pkpass', legs: [leg], }; } function eventCard(pass: Obj, semantics: Obj): EventBooking { const name = text(semantics, 'eventName'); // Event tickets have no zone tag, so a UTC time can't be placed: doubtful. const start = placeTime(semantics, undefined, date(semantics, 'eventStartDate'), false); if (!name || !start) doubt(); let end = placeTime(semantics, undefined, date(semantics, 'eventEndDate'), false); // A date-only end next to a timed start says nothing reliable about when it ends. if (end && !end.time && start.time) end = undefined; const venueName = text(semantics, 'venueName'); const region = text(semantics, 'venueRegionName'); const venue: Place = { ...(venueName ? { name: venueName } : {}), ...(region ? { address: { locality: region } } : {}), }; const attendee = text(semantics, 'attendeeName'); const reference = text(semantics, 'confirmationNumber'); const organization = str(pass.organizationName); return { key: '', kind: 'event', ...(reference ? { reference } : {}), passengers: attendee ? [attendee] : [], ...(organization ? { provider: organization } : {}), links: {}, source: 'pkpass', name, ...(venue.name || venue.address ? { venue } : {}), start, ...(end ? { end } : {}), tickets: [], }; } /** * The cards a parsed pass.json describes: at most one, validated against * the mail's date (validateCard), with its merge key, zones not yet resolved. * Empty for any pass that isn't a boarding pass for air, train or bus or an * event ticket, for a voided pass, for a pass without `semantics`, and for * anything doubtful. Never throws. */ export function passToCards(pass: unknown, ctx: { mailDate: Date }): StructuredCard[] { try { if (!isObj(pass) || pass.formatVersion !== 1 || pass.voided === true) return []; const styles = PASS_STYLES.filter((style) => pass[style] !== undefined); if (styles.length !== 1) return []; const semantics = pass.semantics; if (!isObj(semantics)) return []; let card: StructuredCard; if (styles[0] === 'boardingPass') { const boarding = pass.boardingPass; if (!isObj(boarding) || typeof boarding.transitType !== 'string') return []; const kind = TRANSIT_KINDS[boarding.transitType]; if (!kind) return []; card = transportCard(kind, pass, semantics); } else if (styles[0] === 'eventTicket') { if (!isObj(pass.eventTicket)) return []; card = eventCard(pass, semantics); } else { return []; } const keyed = { ...card, key: cardKey(card) } as StructuredCard; return validateCard(keyed, ctx.mailDate).problems.length === 0 ? [keyed] : []; } catch { return []; } } /** readPassJson + passToCards. */ export async function readPkpass(bytes: ArrayBuffer | Uint8Array, ctx: { mailDate: Date }, options?: ReadPassOptions): Promise { const pass = await readPassJson(bytes, options); return pass ? passToCards(pass, ctx) : []; }