@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.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.
@@ -0,0 +1,317 @@
1
+ /**
2
+ * spec.js - what Apple requires of a pass, as data.
3
+ *
4
+ * Everything here is a table rather than a code path, because all of it is
5
+ * somebody else's specification and the only useful property is that it is easy
6
+ * to correct. Nothing in this file is tied to an airline, a brand or a project;
7
+ * the five styles are the five Apple defines.
8
+ *
9
+ * The enum lists matter more than they look. A single invalid
10
+ * `PKPassengerCapability*` value does not produce an error - Wallet silently
11
+ * declines to render the enhanced layout and the pass falls back to the old
12
+ * one, with nothing anywhere saying why. That failure mode is the reason this
13
+ * file exists at all.
14
+ *
15
+ * @module tools/pass-kit/spec
16
+ */
17
+
18
+ export const STYLES = ["boardingPass", "coupon", "eventTicket", "generic", "storeCard"];
19
+
20
+ /**
21
+ * Top-level keys every pass must carry, whatever its style.
22
+ * Apple rejects a pass missing any of these outright.
23
+ */
24
+ export const REQUIRED_TOP_LEVEL = [
25
+ "description",
26
+ "formatVersion",
27
+ "organizationName",
28
+ "passTypeIdentifier",
29
+ "serialNumber",
30
+ "teamIdentifier",
31
+ ];
32
+
33
+ /**
34
+ * Images, per style.
35
+ *
36
+ * `required` is what Wallet will not render without. `optional` is what the
37
+ * style can use. Anything outside both lists is dead weight in the manifest -
38
+ * worth reporting, because it is usually a naming mistake rather than a spare.
39
+ *
40
+ * Retina variants are implied: `logo` means logo.png, logo@2x.png, logo@3x.png,
41
+ * and the 1x file carries no suffix. That last detail is a real trap - a file
42
+ * named `footer@1x.png` is not the 1x footer, it is a file Wallet ignores.
43
+ */
44
+ export const ASSETS = {
45
+ boardingPass: { required: ["icon", "logo"], optional: ["footer", "background", "thumbnail"] },
46
+ coupon: { required: ["icon", "logo"], optional: ["strip"] },
47
+ eventTicket: { required: ["icon", "logo"], optional: ["strip", "background", "thumbnail"] },
48
+ generic: { required: ["icon", "logo"], optional: ["thumbnail"] },
49
+ storeCard: { required: ["icon", "logo"], optional: ["strip"] },
50
+ };
51
+
52
+ /** Every image name a pass may legitimately contain, at any scale. */
53
+ export const ALL_IMAGE_BASENAMES = [
54
+ "icon",
55
+ "logo",
56
+ "strip",
57
+ "background",
58
+ "thumbnail",
59
+ "footer",
60
+ "personalizationLogo",
61
+ ];
62
+
63
+ export const SCALES = ["", "@2x", "@3x"];
64
+
65
+ /** boardingPass carries one extra required key inside its style dictionary. */
66
+ export const STYLE_REQUIRED_KEYS = {
67
+ boardingPass: ["transitType"],
68
+ };
69
+
70
+ export const TRANSIT_TYPES = [
71
+ "PKTransitTypeAir",
72
+ "PKTransitTypeBoat",
73
+ "PKTransitTypeBus",
74
+ "PKTransitTypeGeneric",
75
+ "PKTransitTypeTrain",
76
+ ];
77
+
78
+ export const BARCODE_FORMATS = [
79
+ "PKBarcodeFormatQR",
80
+ "PKBarcodeFormatPDF417",
81
+ "PKBarcodeFormatAztec",
82
+ "PKBarcodeFormatCode128",
83
+ ];
84
+
85
+ export const FIELD_BUCKETS = [
86
+ "headerFields",
87
+ "primaryFields",
88
+ "secondaryFields",
89
+ "auxiliaryFields",
90
+ "backFields",
91
+ // Newer than the other five and only meaningful on a boardingPass. Listed
92
+ // because a real, working, signed enhanced boarding pass uses it: reporting
93
+ // it as "a bucket Apple does not define" was this validator being wrong
94
+ // about somebody else's correct pass, which is worse than saying nothing.
95
+ "footerFields",
96
+ ];
97
+
98
+ /**
99
+ * Enumerated semantic values.
100
+ *
101
+ * An unknown value in one of these is the quiet failure described in the file
102
+ * header: no error, no warning, and an enhanced pass that silently renders as a
103
+ * legacy one.
104
+ */
105
+ export const SEMANTIC_ENUMS = {
106
+ passengerCapabilities: [
107
+ "PKPassengerCapabilityPreBoarding",
108
+ "PKPassengerCapabilityPriorityBoarding",
109
+ "PKPassengerCapabilityCarryon",
110
+ "PKPassengerCapabilityPersonalItem",
111
+ ],
112
+ departureLocationSecurityPrograms: [
113
+ "PKTransitSecurityProgramTSAPreCheck",
114
+ "PKTransitSecurityProgramGlobalEntry",
115
+ "PKTransitSecurityProgramClear",
116
+ "PKTransitSecurityProgramNexus",
117
+ "PKTransitSecurityProgramSentri",
118
+ "PKTransitSecurityProgramTSAPreCheckTouchlessID",
119
+ "PKTransitSecurityProgramFastTrack",
120
+ ],
121
+ eventType: [
122
+ "PKEventTypeGeneric",
123
+ "PKEventTypeLivePerformance",
124
+ "PKEventTypeMovie",
125
+ "PKEventTypeSports",
126
+ "PKEventTypeConference",
127
+ "PKEventTypeConvention",
128
+ "PKEventTypeWorkshop",
129
+ "PKEventTypeSocialGathering",
130
+ ],
131
+ };
132
+
133
+ SEMANTIC_ENUMS.destinationLocationSecurityPrograms =
134
+ SEMANTIC_ENUMS.departureLocationSecurityPrograms;
135
+ SEMANTIC_ENUMS.passengerEligibleSecurityPrograms =
136
+ SEMANTIC_ENUMS.departureLocationSecurityPrograms;
137
+
138
+ /**
139
+ * Semantic tags, by the type a validator can actually check.
140
+ *
141
+ * Not exhaustive of Apple's catalogue and not trying to be - it covers the
142
+ * shapes that are wrong often enough to be worth catching: a date that is not
143
+ * ISO 8601, a number sent as a string, a location missing a coordinate.
144
+ */
145
+ export const SEMANTIC_TYPES = {
146
+ // Flight and transit
147
+ airlineCode: "string",
148
+ flightCode: "string",
149
+ flightNumber: "number",
150
+ departureAirportCode: "string",
151
+ departureAirportName: "string",
152
+ departureCityName: "string",
153
+ departureStationName: "string",
154
+ departurePlatform: "string",
155
+ departureLocationDescription: "string",
156
+ departureGate: "string",
157
+ departureTerminal: "string",
158
+ departureLocation: "location",
159
+ departureLocationTimeZone: "string",
160
+ destinationAirportCode: "string",
161
+ destinationAirportName: "string",
162
+ destinationCityName: "string",
163
+ destinationStationName: "string",
164
+ destinationPlatform: "string",
165
+ destinationLocationDescription: "string",
166
+ destinationGate: "string",
167
+ destinationTerminal: "string",
168
+ destinationLocation: "location",
169
+ destinationLocationTimeZone: "string",
170
+ boardingGroup: "string",
171
+ boardingSequenceNumber: "string",
172
+ boardingZone: "string",
173
+ originalBoardingDate: "date",
174
+ currentBoardingDate: "date",
175
+ originalDepartureDate: "date",
176
+ currentDepartureDate: "date",
177
+ originalArrivalDate: "date",
178
+ currentArrivalDate: "date",
179
+ transitProvider: "string",
180
+ transitStatus: "string",
181
+ transitStatusReason: "string",
182
+ vehicleName: "string",
183
+ vehicleNumber: "string",
184
+ vehicleType: "string",
185
+ carNumber: "string",
186
+ confirmationNumber: "string",
187
+ passengerName: "personName",
188
+ membershipProgramName: "string",
189
+ membershipProgramNumber: "string",
190
+ priorityStatus: "string",
191
+ ticketFareClass: "string",
192
+ securityScreening: "string",
193
+ membershipProgramStatus: "string",
194
+ internationalDocumentsAreVerified: "boolean",
195
+ internationalDocumentsVerifiedDeclarationName: "string",
196
+ passengerAirlineSSRs: "array",
197
+ passengerInformationSSRs: "array",
198
+ passengerServiceSSRs: "array",
199
+ loungePlaceIDs: "array",
200
+ wifiAccess: "array",
201
+ seats: "array",
202
+ // Event
203
+ eventName: "string",
204
+ eventType: "string",
205
+ eventStartDate: "date",
206
+ eventEndDate: "date",
207
+ venueName: "string",
208
+ venueLocation: "location",
209
+ venueRoom: "string",
210
+ venueEntrance: "string",
211
+ venueRegionName: "string",
212
+ venuePhoneNumber: "string",
213
+ venueOpenDate: "date",
214
+ venueDoorsOpenDate: "date",
215
+ venueGatesOpenDate: "date",
216
+ venueCloseDate: "date",
217
+ entranceDescription: "string",
218
+ admissionLevelAbbreviation: "string",
219
+ additionalTicketAttributes: "string",
220
+ tailgatingAllowed: "boolean",
221
+ admissionLevel: "string",
222
+ attendeeName: "string",
223
+ performerNames: "array",
224
+ genre: "string",
225
+ sportName: "string",
226
+ leagueName: "string",
227
+ leagueAbbreviation: "string",
228
+ homeTeamName: "string",
229
+ homeTeamAbbreviation: "string",
230
+ homeTeamLocation: "string",
231
+ awayTeamName: "string",
232
+ awayTeamAbbreviation: "string",
233
+ awayTeamLocation: "string",
234
+ artistIDs: "array",
235
+ albumIDs: "array",
236
+ playlistIDs: "array",
237
+ // General
238
+ totalPrice: "currency",
239
+ balance: "currency",
240
+ duration: "number",
241
+ silenceRequested: "boolean",
242
+ };
243
+
244
+ /**
245
+ * Keys renamed between iOS 18 and iOS 26.
246
+ *
247
+ * A pass carrying the old spelling is not rejected; the tag is ignored, which
248
+ * means the feature it drives silently does not appear.
249
+ */
250
+ export const RENAMED_SEMANTICS = {
251
+ departureAirportTimeZone: "departureLocationTimeZone",
252
+ destinationAirportTimeZone: "destinationLocationTimeZone",
253
+ airlinePassengerCapabilities: "passengerCapabilities",
254
+ };
255
+
256
+ /**
257
+ * Semantic tags an enhanced boarding pass needs before Wallet will use the
258
+ * richer layout. Missing any of them is not an error - the pass simply renders
259
+ * the way it did before, which is exactly why it is worth reporting.
260
+ */
261
+ export const ENHANCED_BOARDING_PASS_TAGS = [
262
+ "airlineCode",
263
+ "flightNumber",
264
+ "departureAirportCode",
265
+ "destinationAirportCode",
266
+ "originalDepartureDate",
267
+ "originalArrivalDate",
268
+ "originalBoardingDate",
269
+ "departureLocationTimeZone",
270
+ "destinationLocationTimeZone",
271
+ ];
272
+
273
+ const ISO_8601 =
274
+ /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
275
+
276
+ /**
277
+ * @returns {string|null} why the value is wrong for its declared type
278
+ */
279
+ export function checkSemanticValue(key, type, value) {
280
+ switch (type) {
281
+ case "string":
282
+ return typeof value === "string" ? null : `must be a string, got ${typeOf(value)}`;
283
+ case "number":
284
+ return typeof value === "number" && Number.isFinite(value)
285
+ ? null
286
+ : `must be a number, got ${typeOf(value)}`;
287
+ case "boolean":
288
+ return typeof value === "boolean" ? null : `must be a boolean, got ${typeOf(value)}`;
289
+ case "array":
290
+ return Array.isArray(value) ? null : `must be an array, got ${typeOf(value)}`;
291
+ case "date":
292
+ if (typeof value !== "string") return `must be an ISO 8601 string, got ${typeOf(value)}`;
293
+ return ISO_8601.test(value) ? null : `"${value}" is not ISO 8601`;
294
+ case "location":
295
+ if (!value || typeof value !== "object") return `must be an object with latitude and longitude`;
296
+ if (typeof value.latitude !== "number" || typeof value.longitude !== "number") {
297
+ return "latitude and longitude are both required and must be numbers";
298
+ }
299
+ return null;
300
+ case "currency":
301
+ if (!value || typeof value !== "object") return "must be an object";
302
+ if (typeof value.currencyCode !== "string") return "currencyCode is required";
303
+ if (typeof value.amount !== "string") return "amount must be a STRING, not a number";
304
+ return null;
305
+ case "personName":
306
+ if (!value || typeof value !== "object") return "must be an object of name components";
307
+ return null;
308
+ default:
309
+ return null;
310
+ }
311
+ }
312
+
313
+ function typeOf(v) {
314
+ if (v === null) return "null";
315
+ if (Array.isArray(v)) return "array";
316
+ return typeof v;
317
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * validate.js - every check that can be made without signing anything.
3
+ *
4
+ * Pure: it takes a parsed pass.json and a list of file names, and returns
5
+ * findings. No I/O, so it is the same function whether it is checking a draft
6
+ * before a build or an existing `.pkpass` after one.
7
+ *
8
+ * Findings are graded because the consequences genuinely differ:
9
+ *
10
+ * error Wallet refuses the pass, or the pass is wrong about itself.
11
+ * warning Wallet accepts it and quietly does less than intended - the
12
+ * enhanced layout falls back, a localized string never resolves, a
13
+ * renamed tag is ignored. This grade is the point of the file.
14
+ * note Harmless, but somebody meant something by it.
15
+ *
16
+ * @module tools/pass-kit/validate
17
+ */
18
+
19
+ import {
20
+ STYLES,
21
+ REQUIRED_TOP_LEVEL,
22
+ ASSETS,
23
+ ALL_IMAGE_BASENAMES,
24
+ STYLE_REQUIRED_KEYS,
25
+ TRANSIT_TYPES,
26
+ BARCODE_FORMATS,
27
+ FIELD_BUCKETS,
28
+ SEMANTIC_ENUMS,
29
+ SEMANTIC_TYPES,
30
+ RENAMED_SEMANTICS,
31
+ ENHANCED_BOARDING_PASS_TAGS,
32
+ checkSemanticValue,
33
+ } from "./spec.js";
34
+
35
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
36
+ const RGB_COLOR = /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/;
37
+
38
+ /**
39
+ * @param {object} pass parsed pass.json
40
+ * @param {string[]} files file names present beside it, e.g. ["icon.png", "en.lproj/pass.strings"]
41
+ * @param {object} [opts]
42
+ * @returns {{style: string|null, errors: object[], warnings: object[], notes: object[], ok: boolean}}
43
+ */
44
+ export function validatePass(pass, files = [], opts = {}) {
45
+ const errors = [];
46
+ const warnings = [];
47
+ const notes = [];
48
+ const add = (list, where, message) => list.push({ where, message });
49
+
50
+ if (!pass || typeof pass !== "object") {
51
+ return { style: null, errors: [{ where: "pass.json", message: "not a JSON object" }], warnings, notes, ok: false };
52
+ }
53
+
54
+ for (const key of REQUIRED_TOP_LEVEL) {
55
+ if (pass[key] === undefined || pass[key] === "") add(errors, key, "required top-level key is missing");
56
+ }
57
+ if (pass.formatVersion !== undefined && pass.formatVersion !== 1) {
58
+ add(errors, "formatVersion", `must be 1, got ${JSON.stringify(pass.formatVersion)}`);
59
+ }
60
+
61
+ const present = STYLES.filter((s) => pass[s] !== undefined);
62
+ let style = null;
63
+ if (present.length === 0) {
64
+ add(errors, "pass.json", `no style dictionary: exactly one of ${STYLES.join(", ")} is required`);
65
+ } else if (present.length > 1) {
66
+ add(errors, "pass.json", `${present.length} style dictionaries (${present.join(", ")}); a pass has exactly one`);
67
+ } else {
68
+ style = present[0];
69
+ }
70
+
71
+ if (style) {
72
+ const body = pass[style] || {};
73
+ for (const key of STYLE_REQUIRED_KEYS[style] || []) {
74
+ if (body[key] === undefined) add(errors, `${style}.${key}`, "required for this style");
75
+ }
76
+ if (style === "boardingPass" && body.transitType && !TRANSIT_TYPES.includes(body.transitType)) {
77
+ add(errors, "boardingPass.transitType", `"${body.transitType}" is not one of ${TRANSIT_TYPES.join(", ")}`);
78
+ }
79
+ for (const bucket of Object.keys(body)) {
80
+ if (!FIELD_BUCKETS.includes(bucket) && bucket !== "transitType") {
81
+ add(notes, `${style}.${bucket}`, "not a field bucket Apple defines; it will be ignored");
82
+ }
83
+ }
84
+ for (const bucket of FIELD_BUCKETS) {
85
+ const rows = body[bucket];
86
+ if (rows === undefined) continue;
87
+ if (!Array.isArray(rows)) {
88
+ add(errors, `${style}.${bucket}`, "must be an array");
89
+ continue;
90
+ }
91
+ rows.forEach((f, i) => {
92
+ const at = `${style}.${bucket}[${i}]`;
93
+ if (!f || typeof f !== "object") return add(errors, at, "must be an object");
94
+ if (typeof f.key !== "string" || !f.key) add(errors, at, "`key` is required");
95
+ if (f.value === undefined) add(errors, at, "`value` is required");
96
+ });
97
+ }
98
+ }
99
+
100
+ checkColors(pass, errors);
101
+ checkBarcodes(pass, errors, warnings);
102
+ checkSemantics(pass, style, errors, warnings, notes, opts);
103
+ checkWebService(pass, errors, warnings);
104
+ checkAssets(style, files, errors, warnings, notes);
105
+ checkLocalization(pass, style, files, warnings);
106
+ checkLabels(pass, style, files, warnings);
107
+
108
+ return { style, errors, warnings, notes, ok: errors.length === 0 };
109
+ }
110
+
111
+ function checkColors(pass, errors) {
112
+ for (const key of ["foregroundColor", "backgroundColor", "labelColor", "stripColor"]) {
113
+ const v = pass[key];
114
+ if (v === undefined) continue;
115
+ if (typeof v !== "string" || (!HEX_COLOR.test(v) && !RGB_COLOR.test(v))) {
116
+ errors.push({ where: key, message: `must be #RRGGBB or rgb(r,g,b), got ${JSON.stringify(v)}` });
117
+ }
118
+ }
119
+ }
120
+
121
+ function checkBarcodes(pass, errors, warnings) {
122
+ const list = pass.barcodes;
123
+ if (list === undefined) {
124
+ if (pass.barcode === undefined) {
125
+ warnings.push({ where: "barcodes", message: "no barcode: the pass will scan nowhere" });
126
+ }
127
+ return;
128
+ }
129
+ if (!Array.isArray(list)) return errors.push({ where: "barcodes", message: "must be an array" });
130
+ list.forEach((b, i) => {
131
+ const at = `barcodes[${i}]`;
132
+ if (!b || typeof b !== "object") return errors.push({ where: at, message: "must be an object" });
133
+ if (!BARCODE_FORMATS.includes(b.format)) {
134
+ errors.push({ where: `${at}.format`, message: `"${b.format}" is not one of ${BARCODE_FORMATS.join(", ")}` });
135
+ }
136
+ if (typeof b.message !== "string" || !b.message) {
137
+ errors.push({ where: `${at}.message`, message: "required" });
138
+ }
139
+ if (typeof b.messageEncoding !== "string" || !b.messageEncoding) {
140
+ errors.push({ where: `${at}.messageEncoding`, message: 'required, usually "iso-8859-1"' });
141
+ }
142
+ });
143
+ }
144
+
145
+ function checkSemantics(pass, style, errors, warnings, notes, opts) {
146
+ // Semantics live at the top level and may also appear per field. Both are
147
+ // checked; the top-level block is the one the enhanced layouts read.
148
+ const blocks = [{ at: "semantics", value: pass.semantics }];
149
+ if (style && pass[style]) {
150
+ for (const bucket of FIELD_BUCKETS) {
151
+ (pass[style][bucket] || []).forEach((f, i) => {
152
+ if (f && f.semantics) blocks.push({ at: `${style}.${bucket}[${i}].semantics`, value: f.semantics });
153
+ });
154
+ }
155
+ }
156
+
157
+ for (const { at, value } of blocks) {
158
+ if (value === undefined) continue;
159
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
160
+ errors.push({ where: at, message: "must be an object" });
161
+ continue;
162
+ }
163
+ for (const [key, v] of Object.entries(value)) {
164
+ const renamedTo = RENAMED_SEMANTICS[key];
165
+ if (renamedTo) {
166
+ warnings.push({
167
+ where: `${at}.${key}`,
168
+ message: `renamed to "${renamedTo}" in iOS 26. The old spelling is not rejected, it is ignored - so whatever it drives silently does not appear`,
169
+ });
170
+ continue;
171
+ }
172
+ const allowed = SEMANTIC_ENUMS[key];
173
+ if (allowed) {
174
+ const values = Array.isArray(v) ? v : [v];
175
+ for (const one of values) {
176
+ if (!allowed.includes(one)) {
177
+ errors.push({
178
+ where: `${at}.${key}`,
179
+ message: `"${one}" is not a value Apple defines. One unknown value here does not raise an error: Wallet declines the enhanced layout and falls back silently. Allowed: ${allowed.join(", ")}`,
180
+ });
181
+ }
182
+ }
183
+ continue;
184
+ }
185
+ const type = SEMANTIC_TYPES[key];
186
+ if (!type) {
187
+ notes.push({ where: `${at}.${key}`, message: "not a semantic tag this validator knows; it may be new or misspelled" });
188
+ continue;
189
+ }
190
+ const bad = checkSemanticValue(key, type, v);
191
+ if (bad) errors.push({ where: `${at}.${key}`, message: bad });
192
+ }
193
+ }
194
+
195
+ const top = pass.semantics || {};
196
+ // `original*` is the schedule and `current*` is the live value. Rewriting
197
+ // `original*` on an update destroys the only record of what changed, which is
198
+ // what the delay notifications are computed from.
199
+ for (const pair of [
200
+ ["originalDepartureDate", "currentDepartureDate"],
201
+ ["originalArrivalDate", "currentArrivalDate"],
202
+ ["originalBoardingDate", "currentBoardingDate"],
203
+ ]) {
204
+ if (top[pair[1]] !== undefined && top[pair[0]] === undefined) {
205
+ warnings.push({
206
+ where: `semantics.${pair[1]}`,
207
+ message: `set without ${pair[0]}. The original is the schedule and the current is the live value; with no original there is nothing to compare against`,
208
+ });
209
+ }
210
+ }
211
+
212
+ if (style === "boardingPass" && opts.enhanced !== false) {
213
+ const missing = ENHANCED_BOARDING_PASS_TAGS.filter((t) => top[t] === undefined);
214
+ if (missing.length && missing.length < ENHANCED_BOARDING_PASS_TAGS.length) {
215
+ warnings.push({
216
+ where: "semantics",
217
+ message: `an enhanced boarding pass wants ${missing.length} more tag(s): ${missing.join(", ")}. Missing them is not an error - the pass renders the older layout instead, with nothing saying why`,
218
+ });
219
+ }
220
+ }
221
+ }
222
+
223
+ function checkWebService(pass, errors, warnings) {
224
+ const hasUrl = typeof pass.webServiceURL === "string" && pass.webServiceURL !== "";
225
+ const hasToken = typeof pass.authenticationToken === "string" && pass.authenticationToken !== "";
226
+ if (hasUrl !== hasToken) {
227
+ errors.push({
228
+ where: hasUrl ? "authenticationToken" : "webServiceURL",
229
+ message: "webServiceURL and authenticationToken go together; one without the other disables updates",
230
+ });
231
+ }
232
+ if (hasUrl && !/^https:\/\//i.test(pass.webServiceURL)) {
233
+ errors.push({ where: "webServiceURL", message: "must be https" });
234
+ }
235
+ if (hasToken && pass.authenticationToken.length < 16) {
236
+ warnings.push({
237
+ where: "authenticationToken",
238
+ message: "shorter than 16 characters; Apple requires at least 16",
239
+ });
240
+ }
241
+ }
242
+
243
+ function checkAssets(style, files, errors, warnings, notes) {
244
+ if (!style) return;
245
+ const names = new Set(files.map((f) => f.split("/").pop()));
246
+ const has = (base) => names.has(`${base}.png`) || names.has(`${base}@2x.png`) || names.has(`${base}@3x.png`);
247
+ const contract = ASSETS[style] || { required: [], optional: [] };
248
+
249
+ for (const base of contract.required) {
250
+ if (!has(base)) {
251
+ // Tagged, because the pass.json-only caller has no files to check and has
252
+ // to drop these. It used to drop them by matching the message text, which
253
+ // stops working the moment the wording is improved - silently, and in the
254
+ // direction of reporting missing images that were never claimed present.
255
+ errors.push({ where: `${base}.png`, message: `required for a ${style}`, kind: "asset" });
256
+ }
257
+ }
258
+ for (const base of ALL_IMAGE_BASENAMES) {
259
+ if (!has(base)) continue;
260
+ if (contract.required.includes(base) || contract.optional.includes(base)) continue;
261
+ notes.push({ where: `${base}.png`, message: `a ${style} does not use this image; it is weight in the manifest` });
262
+ }
263
+ // The 1x file carries NO suffix. `footer@1x.png` is not the 1x footer - it is
264
+ // a file Wallet never looks for, hashed into the manifest for nothing.
265
+ for (const n of names) {
266
+ if (/@1x\.png$/.test(n)) {
267
+ warnings.push({
268
+ where: n,
269
+ message: `the 1x variant has no suffix. Wallet looks for "${n.replace("@1x", "")}" and will not find this file`,
270
+ });
271
+ }
272
+ }
273
+ for (const base of contract.required) {
274
+ if (has(base) && !names.has(`${base}@2x.png`)) {
275
+ notes.push({ where: `${base}@2x.png`, message: "no @2x variant; it will be upscaled on every modern device" });
276
+ }
277
+ }
278
+ }
279
+
280
+ function checkLocalization(pass, style, files, warnings) {
281
+ const lprojs = new Set(
282
+ files.filter((f) => f.includes(".lproj/")).map((f) => f.split(".lproj/")[0].split("/").pop()),
283
+ );
284
+ if (lprojs.size === 0) return;
285
+ const strings = new Set(files.filter((f) => f.endsWith(".lproj/pass.strings")).map((f) => f.split(".lproj/")[0].split("/").pop()));
286
+ for (const l of lprojs) {
287
+ if (!strings.has(l)) {
288
+ warnings.push({ where: `${l}.lproj`, message: "no pass.strings; every label in this language falls back" });
289
+ }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Labels, which are the quietest way a pass ends up half-translated.
295
+ *
296
+ * Deliberately NOT inside checkLocalization: this first lived there, behind its
297
+ * early return, and therefore never ran on a pass with no .lproj folder at all -
298
+ * which is the case where a key-shaped label is worst, because it renders as the
299
+ * key. A test caught it.
300
+ */
301
+ function checkLabels(pass, style, files, warnings) {
302
+ if (!style || !pass[style]) return;
303
+ const labels = [];
304
+ for (const bucket of FIELD_BUCKETS) {
305
+ (pass[style][bucket] || []).forEach((f) => {
306
+ if (f && typeof f.label === "string" && f.label) labels.push(f.label);
307
+ });
308
+ }
309
+ if (labels.length === 0) return;
310
+
311
+ const keyish = labels.filter((l) => /^[a-z][A-Za-z0-9_]*$/.test(l) || /_/.test(l));
312
+ const hasStrings = files.some((f) => f.endsWith(".lproj/pass.strings"));
313
+
314
+ if (keyish.length > 0 && !hasStrings) {
315
+ warnings.push({
316
+ where: `${style} labels`,
317
+ message: `${keyish.length} label(s) look like localization keys (${keyish.slice(0, 4).map((l) => JSON.stringify(l)).join(", ")}) but no .lproj/pass.strings was found. An unresolved key renders as the key itself`,
318
+ });
319
+ return;
320
+ }
321
+
322
+ if (labels.length >= 2 && keyish.length > 0 && keyish.length < labels.length) {
323
+ const literals = labels.filter((l) => !keyish.includes(l)).slice(0, 4);
324
+ warnings.push({
325
+ where: `${style} labels`,
326
+ message: `${keyish.length} of ${labels.length} labels look like localization keys and the rest look like literal text (e.g. ${literals.map((l) => JSON.stringify(l)).join(", ")}). Mixed labels localize the keys and leave the literals in one language`,
327
+ });
328
+ }
329
+ }