@hyodotdev/openiap 0.0.0-bootstrap.0 → 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 +152 -4
- package/bin/openiap.mjs +110 -0
- package/package.json +26 -5
- package/src/checks.mjs +610 -0
- package/src/doctor.mjs +183 -0
- package/src/findings.mjs +33 -0
- package/src/init.mjs +95 -0
- package/src/project.mjs +416 -0
package/src/checks.mjs
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUNDLED_FILES,
|
|
3
|
+
clientFiles,
|
|
4
|
+
dependencies,
|
|
5
|
+
envValue,
|
|
6
|
+
hasUnreadablePath,
|
|
7
|
+
isDirectory,
|
|
8
|
+
isEnvFile,
|
|
9
|
+
listDir,
|
|
10
|
+
parseEnv,
|
|
11
|
+
parseProperties,
|
|
12
|
+
read,
|
|
13
|
+
readFirst,
|
|
14
|
+
codeLines,
|
|
15
|
+
isGeneratedDir,
|
|
16
|
+
pubspecAssets,
|
|
17
|
+
quoted,
|
|
18
|
+
walkFiles,
|
|
19
|
+
} from "./project.mjs";
|
|
20
|
+
import { finding } from "./findings.mjs";
|
|
21
|
+
|
|
22
|
+
function withoutXmlComments(text) {
|
|
23
|
+
return text.replace(/<!--[\s\S]*?-->/g, (comment) =>
|
|
24
|
+
comment.replace(/[^\r\n]/g, " "),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A value read from a project file is data, never a line of this report. */
|
|
29
|
+
function oneLine(value) {
|
|
30
|
+
return (
|
|
31
|
+
value
|
|
32
|
+
// Escapes and bidi overrides can repaint a line they did not write.
|
|
33
|
+
.replace(
|
|
34
|
+
/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g,
|
|
35
|
+
"",
|
|
36
|
+
)
|
|
37
|
+
.replace(/\s+/g, " ")
|
|
38
|
+
.trim()
|
|
39
|
+
.slice(0, 60)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A plist can name anything, and that name is spliced into a RegExp. */
|
|
44
|
+
function escapeRegExp(value) {
|
|
45
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lineAt(text, index) {
|
|
49
|
+
return text.slice(0, index).split("\n").length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The store a build links. The second argument is a literal in a generated
|
|
54
|
+
* project and a variable in a hand-written one, and a variable names a store
|
|
55
|
+
* only Gradle can resolve.
|
|
56
|
+
*/
|
|
57
|
+
const PLATFORM_STRATEGY =
|
|
58
|
+
/^[ \t]*missingDimensionStrategy[\s(]{0,4}["']platform["'][\s,]{0,8}(?:["'](\w+)["']|(\w+))/;
|
|
59
|
+
|
|
60
|
+
const APP_BUILD_FILES = [
|
|
61
|
+
"android/app/build.gradle",
|
|
62
|
+
"android/app/build.gradle.kts",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A generated Android project is written in one pass, so its store flag and
|
|
67
|
+
* its flavor literal always agree when they are fresh. A disagreement means a
|
|
68
|
+
* half-finished regeneration, and the app links a store the device may not run.
|
|
69
|
+
*/
|
|
70
|
+
export function androidStoreChecks(root) {
|
|
71
|
+
const propertiesText = read(root, "android/gradle.properties");
|
|
72
|
+
const app = readFirst(root, APP_BUILD_FILES);
|
|
73
|
+
// Either file alone still proves which store the build links.
|
|
74
|
+
if (propertiesText === null && !app) return [];
|
|
75
|
+
|
|
76
|
+
const properties = propertiesText ? parseProperties(propertiesText) : null;
|
|
77
|
+
// OpenIAP Gradle scripts read these properties with Groovy toBoolean().
|
|
78
|
+
const enabled = (name) =>
|
|
79
|
+
["true", "1", "y"].includes(
|
|
80
|
+
properties?.get(name)?.value.trim().toLowerCase(),
|
|
81
|
+
);
|
|
82
|
+
const horizon = enabled("horizonEnabled");
|
|
83
|
+
const fireOs = enabled("fireOsEnabled");
|
|
84
|
+
const findings = [];
|
|
85
|
+
|
|
86
|
+
if (horizon && fireOs) {
|
|
87
|
+
findings.push(
|
|
88
|
+
finding(
|
|
89
|
+
"android-store-flavor-conflict",
|
|
90
|
+
"error",
|
|
91
|
+
"android/gradle.properties",
|
|
92
|
+
"horizonEnabled and fireOsEnabled are both true.",
|
|
93
|
+
"Leave one store enabled and regenerate the Android project.",
|
|
94
|
+
{ line: properties.get("horizonEnabled")?.line },
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Gradle comments hold disabled configuration; reading them reports fiction.
|
|
100
|
+
const strategies = app
|
|
101
|
+
? codeLines(app.text)
|
|
102
|
+
.map((one) => ({ ...one, match: PLATFORM_STRATEGY.exec(one.line) }))
|
|
103
|
+
.filter((one) => one.match)
|
|
104
|
+
: [];
|
|
105
|
+
const stores = [
|
|
106
|
+
...new Set(strategies.map((one) => one.match[1]).filter(Boolean)),
|
|
107
|
+
];
|
|
108
|
+
// A variable second argument names a store this cannot resolve.
|
|
109
|
+
const computed = strategies.some((one) => !one.match[1]);
|
|
110
|
+
// Play billing works if any flavor links Play, so only a build that links no
|
|
111
|
+
// Play flavor at all is worth reporting.
|
|
112
|
+
const linksNonPlay = stores.includes("play")
|
|
113
|
+
? undefined
|
|
114
|
+
: stores.find((one) => one !== "play");
|
|
115
|
+
// Build types may legitimately link different stores, so a mismatch is not
|
|
116
|
+
// about how many are declared: it is that none of them is the one the flags
|
|
117
|
+
// selected, which only a half-finished regeneration produces.
|
|
118
|
+
const hasStoreFlags =
|
|
119
|
+
properties?.has("fireOsEnabled") || properties?.has("horizonEnabled");
|
|
120
|
+
const selects = hasStoreFlags
|
|
121
|
+
? fireOs
|
|
122
|
+
? "amazon"
|
|
123
|
+
: horizon
|
|
124
|
+
? "horizon"
|
|
125
|
+
: "play"
|
|
126
|
+
: null;
|
|
127
|
+
// A computed flavor may well resolve to the selected store, so a mismatch is
|
|
128
|
+
// only provable when every strategy names a store and none of them is it.
|
|
129
|
+
const missing =
|
|
130
|
+
selects !== null &&
|
|
131
|
+
!computed &&
|
|
132
|
+
stores.length > 0 &&
|
|
133
|
+
!stores.includes(selects);
|
|
134
|
+
const declared = stores.length === 1 && !computed ? stores[0] : null;
|
|
135
|
+
const line = strategies[0]?.number;
|
|
136
|
+
|
|
137
|
+
// With both flags true `selected` is this tool's own tiebreak, not something
|
|
138
|
+
// gradle.properties states, and the conflict finding already covers it.
|
|
139
|
+
if (missing && !(horizon && fireOs)) {
|
|
140
|
+
findings.push(
|
|
141
|
+
finding(
|
|
142
|
+
"android-store-flavor-mismatch",
|
|
143
|
+
"error",
|
|
144
|
+
app.file,
|
|
145
|
+
`The project links ${stores.join(" and ")} while gradle.properties selects ${selects}.`,
|
|
146
|
+
"Regenerate the Android project so both come from one run.",
|
|
147
|
+
{ line, expected: selects, actual: stores.join(",") },
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const store = linksNonPlay ?? declared ?? selects;
|
|
153
|
+
if (store && store !== "play") {
|
|
154
|
+
const evidence =
|
|
155
|
+
declared || linksNonPlay ? app.file : "android/gradle.properties";
|
|
156
|
+
const enabledFlag = fireOs ? "fireOsEnabled" : "horizonEnabled";
|
|
157
|
+
findings.push(
|
|
158
|
+
finding(
|
|
159
|
+
"android-store-not-play",
|
|
160
|
+
"warning",
|
|
161
|
+
evidence,
|
|
162
|
+
computed && !linksNonPlay
|
|
163
|
+
? `gradle.properties selects the ${store} store, and the build computes its flavor from it.`
|
|
164
|
+
: `This Android project is built for the ${store} store.`,
|
|
165
|
+
`Google Play billing will not connect from this build. Regenerate without the ${store} flags before testing on a Play device.`,
|
|
166
|
+
{
|
|
167
|
+
line: linksNonPlay
|
|
168
|
+
? strategies.find((one) => one.match[1] === linksNonPlay)?.number
|
|
169
|
+
: properties?.get(enabledFlag)?.line,
|
|
170
|
+
actual: store,
|
|
171
|
+
},
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (store === "horizon") findings.push(...horizonAppIdCheck(root));
|
|
177
|
+
return findings;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every flavor's manifest, not just `main`: an app can declare the id in the
|
|
182
|
+
* Horizon source set alone. Gradle can also inject it through a placeholder,
|
|
183
|
+
* which no manifest records -- so this reports a suspicion, never a verdict.
|
|
184
|
+
*/
|
|
185
|
+
function horizonAppIdCheck(root) {
|
|
186
|
+
// A test source set merges into the test APK, never the app, so an id
|
|
187
|
+
// declared only there proves nothing about what ships.
|
|
188
|
+
const shipped = listDir(root, "android/app/src").filter(
|
|
189
|
+
(one) => !/^(androidTest|test)/.test(one),
|
|
190
|
+
);
|
|
191
|
+
const manifests = shipped
|
|
192
|
+
.map((one) => `android/app/src/${one}/AndroidManifest.xml`)
|
|
193
|
+
.map((file) => ({ file, text: read(root, file) }))
|
|
194
|
+
.filter(({ text }) => text !== null);
|
|
195
|
+
if (manifests.length === 0) return [];
|
|
196
|
+
const declares = ({ text }) =>
|
|
197
|
+
// An XML comment holds disabled configuration, the same as a Gradle one.
|
|
198
|
+
withoutXmlComments(text).includes(
|
|
199
|
+
"com.meta.horizon.platform.HORIZON_APP_ID",
|
|
200
|
+
);
|
|
201
|
+
if (manifests.some(declares)) return [];
|
|
202
|
+
// Point at the manifest the id belongs in, not whichever the disk listed.
|
|
203
|
+
const preferred =
|
|
204
|
+
manifests.find(({ file }) => file.includes("/horizon/")) ??
|
|
205
|
+
manifests.find(({ file }) => file.includes("/main/")) ??
|
|
206
|
+
manifests[0];
|
|
207
|
+
return [
|
|
208
|
+
finding(
|
|
209
|
+
"android-horizon-app-id-missing",
|
|
210
|
+
"warning",
|
|
211
|
+
preferred.file,
|
|
212
|
+
"The Horizon store is selected but no shipped manifest declares HORIZON_APP_ID.",
|
|
213
|
+
"Set the Horizon app id in your OpenIAP plugin configuration and regenerate, unless Gradle injects it as a manifest placeholder.",
|
|
214
|
+
),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A long tail is what separates a real key from prose naming the prefix. */
|
|
219
|
+
const SECRET_KEY = /openiap-kit_sk_[A-Za-z0-9]{16,}/;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Whether a name assigned in an env file reaches the bundle: a dotenv
|
|
223
|
+
* transform inlines the bare name, Expo's bundler inlines `EXPO_PUBLIC_`, and a
|
|
224
|
+
* Flutter app shipping `.env` as an asset copies the file verbatim.
|
|
225
|
+
*/
|
|
226
|
+
function reachesBundle(root, framework, name, file) {
|
|
227
|
+
const deps = dependencies(root);
|
|
228
|
+
if (DOTENV_PACKAGES.some((one) => deps[one])) return true;
|
|
229
|
+
// Only Expo's bundler inlines this prefix; elsewhere the name is read by
|
|
230
|
+
// nothing, which is what `iapkit-env-unexpected-expo-prefix` reports.
|
|
231
|
+
if (framework === "expo" && name.startsWith("EXPO_PUBLIC_")) return true;
|
|
232
|
+
// Flutter copies the asset paths pubspec declares, and only those.
|
|
233
|
+
return pubspecAssets(root).includes(file);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Distinguish bundled secrets from values used only during configuration. */
|
|
237
|
+
export function secretKeyChecks(root, framework) {
|
|
238
|
+
const findings = [];
|
|
239
|
+
for (const file of clientFiles(root)) {
|
|
240
|
+
const text = read(root, file);
|
|
241
|
+
if (!text) continue;
|
|
242
|
+
|
|
243
|
+
if (isEnvFile(file) || file === "eas.json") {
|
|
244
|
+
let entries;
|
|
245
|
+
if (file === "eas.json") {
|
|
246
|
+
try {
|
|
247
|
+
const config = JSON.parse(text);
|
|
248
|
+
entries = Object.values(config.build ?? {}).flatMap((profile) =>
|
|
249
|
+
["ios", "android"].flatMap((platform) =>
|
|
250
|
+
Object.entries({
|
|
251
|
+
...profile?.env,
|
|
252
|
+
...profile?.[platform]?.env,
|
|
253
|
+
}).map(([name, value]) => ({ name, value })),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
entries = [
|
|
257
|
+
...new Map(
|
|
258
|
+
entries.map((entry) => [JSON.stringify(entry), entry]),
|
|
259
|
+
).values(),
|
|
260
|
+
];
|
|
261
|
+
} catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
entries = parseEnv(text);
|
|
266
|
+
if (!pubspecAssets(root).includes(file)) {
|
|
267
|
+
entries = entries.filter(
|
|
268
|
+
(entry) => envValue(entries, entry.name) === entry,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Parsed, so a key inside a comment is not reported as shipping.
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
if (typeof entry.value !== "string" || !SECRET_KEY.test(entry.value))
|
|
275
|
+
continue;
|
|
276
|
+
// Expo inlines only EXPO_PUBLIC_ names, so a bare name can be the
|
|
277
|
+
// server-side key an API route reads. Anywhere the bundle is not
|
|
278
|
+
// proven, say so rather than demanding a rotation.
|
|
279
|
+
const proven = reachesBundle(root, framework, entry.name, file);
|
|
280
|
+
findings.push(
|
|
281
|
+
proven
|
|
282
|
+
? finding(
|
|
283
|
+
"iapkit-secret-key-in-client",
|
|
284
|
+
"error",
|
|
285
|
+
file,
|
|
286
|
+
`An IAPKit secret key is assigned to ${entry.name}, which reaches the app bundle.`,
|
|
287
|
+
"Move it to your server and use a publishable openiap-kit_pk_ key here. Rotate the exposed key.",
|
|
288
|
+
{ line: entry.line, actual: entry.name },
|
|
289
|
+
)
|
|
290
|
+
: finding(
|
|
291
|
+
"iapkit-secret-key-in-env",
|
|
292
|
+
"warning",
|
|
293
|
+
file,
|
|
294
|
+
`An IAPKit secret key is assigned to ${entry.name}. Nothing here proves it reaches the bundle.`,
|
|
295
|
+
"Keep it off every EXPO_PUBLIC_ name and out of exported app configuration.",
|
|
296
|
+
{ line: entry.line, actual: entry.name },
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const found = codeLines(text).find((one) => SECRET_KEY.test(one.line));
|
|
304
|
+
if (!found) continue;
|
|
305
|
+
const dynamicConfig =
|
|
306
|
+
BUNDLED_FILES.includes(file) && !file.endsWith(".json");
|
|
307
|
+
if (dynamicConfig && !pubspecAssets(root).includes(file)) {
|
|
308
|
+
findings.push(
|
|
309
|
+
finding(
|
|
310
|
+
"iapkit-secret-key-in-config",
|
|
311
|
+
"warning",
|
|
312
|
+
file,
|
|
313
|
+
"An IAPKit secret key appears in executable app configuration. Nothing here proves it reaches the bundle.",
|
|
314
|
+
"Keep build-time secrets out of exported app configuration.",
|
|
315
|
+
{ line: found.number },
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
findings.push(
|
|
321
|
+
finding(
|
|
322
|
+
"iapkit-secret-key-in-client",
|
|
323
|
+
"error",
|
|
324
|
+
file,
|
|
325
|
+
"An IAPKit secret key is in a file the app bundle can read.",
|
|
326
|
+
"Move it to your server and use a publishable openiap-kit_pk_ key here. Rotate the exposed key.",
|
|
327
|
+
{ line: found.number },
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return findings;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const IAPKIT_NAMES = [
|
|
335
|
+
"IAPKIT_API_KEY",
|
|
336
|
+
"IAPKIT_BASE_URL",
|
|
337
|
+
"IAPKIT_PUBLISHABLE_KEY",
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Transforms that inline a bare name into the bundle. Plain `dotenv` is not
|
|
342
|
+
* one: it is a Node library, and the app.config lane it serves is covered by
|
|
343
|
+
* `readAtConfigTime` instead.
|
|
344
|
+
*/
|
|
345
|
+
export const DOTENV_PACKAGES = ["react-native-dotenv", "react-native-config"];
|
|
346
|
+
|
|
347
|
+
/** The ways an app.config can name an env variable, as one pattern each. */
|
|
348
|
+
function envReadPatterns(name) {
|
|
349
|
+
// A dotenv name may hold `.` and `-`, which are regex syntax.
|
|
350
|
+
const safe = escapeRegExp(name);
|
|
351
|
+
return {
|
|
352
|
+
// `delete` removes the value rather than reading it.
|
|
353
|
+
dotted: new RegExp(`(?<!delete\\s{1,8})process\\.env\\??\\.${safe}\\b`),
|
|
354
|
+
indexed: new RegExp(`process\\.env\\??\\[\\s*["'\`]${safe}["'\`]\\s*\\]`),
|
|
355
|
+
// Prettier wraps a destructure over several lines once it is long enough.
|
|
356
|
+
destructured: new RegExp(
|
|
357
|
+
`\\{[^{}]*\\b${safe}\\b[^{}]*\\}\\s*=\\s*process\\.env\\b`,
|
|
358
|
+
"s",
|
|
359
|
+
),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Whether any bundled file names the variable at all. */
|
|
364
|
+
function readAtConfigTime(root, name) {
|
|
365
|
+
const reads = envReadPatterns(name);
|
|
366
|
+
return BUNDLED_FILES.some((file) => {
|
|
367
|
+
const text = read(root, file);
|
|
368
|
+
if (text === null) return false;
|
|
369
|
+
const lines = codeLines(text);
|
|
370
|
+
return (
|
|
371
|
+
reads.destructured.test(lines.map((one) => one.line).join("\n")) ||
|
|
372
|
+
lines.some((one) => {
|
|
373
|
+
const match =
|
|
374
|
+
reads.dotted.exec(one.line) ?? reads.indexed.exec(one.line);
|
|
375
|
+
// A name inside a string on this line is prose, not a read.
|
|
376
|
+
return Boolean(match) && !quoted(one.line, match.index);
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Expo inlines only `EXPO_PUBLIC_`-prefixed variables into the bundle, so the
|
|
384
|
+
* unprefixed name reads as undefined at runtime -- unless something else
|
|
385
|
+
* supplies it, in which case the bare name is correct and demanding the prefix
|
|
386
|
+
* would be wrong advice.
|
|
387
|
+
*/
|
|
388
|
+
export function envNameChecks(root, framework) {
|
|
389
|
+
const deps = dependencies(root);
|
|
390
|
+
if (DOTENV_PACKAGES.some((one) => deps[one])) return [];
|
|
391
|
+
|
|
392
|
+
const findings = [];
|
|
393
|
+
for (const file of clientFiles(root)) {
|
|
394
|
+
if (!isEnvFile(file)) continue;
|
|
395
|
+
const text = read(root, file);
|
|
396
|
+
if (!text) continue;
|
|
397
|
+
const entries = parseEnv(text);
|
|
398
|
+
|
|
399
|
+
// A prefix on another variable says nothing about this one.
|
|
400
|
+
for (const name of IAPKIT_NAMES) {
|
|
401
|
+
const bare = envValue(entries, name);
|
|
402
|
+
const prefixed = envValue(entries, `EXPO_PUBLIC_${name}`);
|
|
403
|
+
if (framework === "expo" && bare && !prefixed) {
|
|
404
|
+
if (SECRET_KEY.test(bare.value)) continue; // secretKeyChecks owns it
|
|
405
|
+
if (readAtConfigTime(root, name)) continue;
|
|
406
|
+
findings.push(
|
|
407
|
+
finding(
|
|
408
|
+
"iapkit-env-missing-expo-prefix",
|
|
409
|
+
"warning",
|
|
410
|
+
file,
|
|
411
|
+
`Expo reads only EXPO_PUBLIC_-prefixed variables in app code, so ${name} is undefined at runtime.`,
|
|
412
|
+
`Rename ${name} to EXPO_PUBLIC_${name}, or read it in app.config where Node resolves it.`,
|
|
413
|
+
{ line: bare.line, actual: name },
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (framework !== "expo" && framework !== "unknown" && prefixed) {
|
|
418
|
+
findings.push(
|
|
419
|
+
finding(
|
|
420
|
+
"iapkit-env-unexpected-expo-prefix",
|
|
421
|
+
"warning",
|
|
422
|
+
file,
|
|
423
|
+
`EXPO_PUBLIC_${name} is set in a ${framework} project, where nothing inlines that prefix.`,
|
|
424
|
+
`Rename it to ${name}, and check how this project reads env files.`,
|
|
425
|
+
{ line: prefixed.line, actual: `EXPO_PUBLIC_${name}` },
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return findings;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The base URL is an origin. IAPKit appends the verification path itself, so a
|
|
436
|
+
* URL that already carries one resolves to a route that does not exist.
|
|
437
|
+
*/
|
|
438
|
+
export function baseUrlChecks(root, framework) {
|
|
439
|
+
const findings = [];
|
|
440
|
+
for (const file of clientFiles(root)) {
|
|
441
|
+
if (!isEnvFile(file)) continue;
|
|
442
|
+
const text = read(root, file);
|
|
443
|
+
if (!text) continue;
|
|
444
|
+
const entries = parseEnv(text);
|
|
445
|
+
for (const name of ["IAPKIT_BASE_URL", "EXPO_PUBLIC_IAPKIT_BASE_URL"]) {
|
|
446
|
+
const entry = envValue(entries, name);
|
|
447
|
+
if (!entry) continue;
|
|
448
|
+
// A value on a name this project never inlines cannot break anything at
|
|
449
|
+
// runtime, so its shape is a suspicion rather than a proven fault.
|
|
450
|
+
const level = reachesBundle(root, framework, name, file)
|
|
451
|
+
? "error"
|
|
452
|
+
: "warning";
|
|
453
|
+
// An empty or interpolated value is filled at build time; nothing here
|
|
454
|
+
// proves it wrong.
|
|
455
|
+
if (entry.value === "" || /^\$|\$\{|\$\(/.test(entry.value)) continue;
|
|
456
|
+
let url;
|
|
457
|
+
try {
|
|
458
|
+
url = new URL(entry.value);
|
|
459
|
+
} catch {
|
|
460
|
+
findings.push(
|
|
461
|
+
finding(
|
|
462
|
+
"iapkit-base-url-invalid",
|
|
463
|
+
level,
|
|
464
|
+
file,
|
|
465
|
+
`${name} is not a URL.`,
|
|
466
|
+
"Use a bare origin such as https://kit.openiap.dev.",
|
|
467
|
+
// The value can be anything, including a credential, so the
|
|
468
|
+
// finding says where it is and never what it is.
|
|
469
|
+
{ line: entry.line },
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
475
|
+
findings.push(
|
|
476
|
+
finding(
|
|
477
|
+
"iapkit-base-url-scheme",
|
|
478
|
+
level,
|
|
479
|
+
file,
|
|
480
|
+
`${name} uses ${url.protocol} instead of http or https.`,
|
|
481
|
+
"Use a bare http(s) origin.",
|
|
482
|
+
{ line: entry.line, actual: url.protocol },
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
// Every verification lane requires a bare origin and throws a developer
|
|
488
|
+
// error otherwise: `OpenIapModule.swift`, `PurchaseVerificationValidator`
|
|
489
|
+
// and both Vega adapters reject a path, userinfo, a query or a fragment.
|
|
490
|
+
// A trailing slash is trimmed first, so `/` is an origin.
|
|
491
|
+
const path = url.pathname !== "" && url.pathname !== "/";
|
|
492
|
+
if (path || url.username || url.password || url.search || url.hash) {
|
|
493
|
+
findings.push(
|
|
494
|
+
finding(
|
|
495
|
+
"iapkit-base-url-has-path",
|
|
496
|
+
level,
|
|
497
|
+
file,
|
|
498
|
+
`${name} is not a bare origin, which is all IAPKit accepts.`,
|
|
499
|
+
"Give the scheme and host only; IAPKit appends /v1/purchase/verify itself.",
|
|
500
|
+
{ line: entry.line },
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return findings;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const IOS_SOURCE = /\.(swift|m|mm|h)$/;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* When the Info.plist names a scene delegate, the class has to exist in the
|
|
513
|
+
* target. Without it UIKit attaches nothing, React Native never starts, and
|
|
514
|
+
* the app shows a black screen with no crash to explain it.
|
|
515
|
+
*/
|
|
516
|
+
export function iosSceneChecks(root, framework) {
|
|
517
|
+
if (framework !== "expo" && framework !== "react-native") return [];
|
|
518
|
+
const findings = [];
|
|
519
|
+
// Sources live wherever the target groups them, so read the whole ios/ tree
|
|
520
|
+
// rather than only the directory holding the plist.
|
|
521
|
+
const sources = walkFiles(root, "ios", IOS_SOURCE).map((one) =>
|
|
522
|
+
read(root, one),
|
|
523
|
+
);
|
|
524
|
+
const unreadableSource =
|
|
525
|
+
sources.some((one) => one === null) || hasUnreadablePath("ios");
|
|
526
|
+
const declarations = sources
|
|
527
|
+
.filter(Boolean)
|
|
528
|
+
.flatMap((one) => codeLines(one).map((line) => line.line));
|
|
529
|
+
|
|
530
|
+
// The app directory is named after the app, so read whatever ios/ holds
|
|
531
|
+
// rather than assuming the name of this repository's own examples.
|
|
532
|
+
const plists = [
|
|
533
|
+
...(read(root, "ios/Info.plist") === null ? [] : ["ios"]),
|
|
534
|
+
...listDir(root, "ios")
|
|
535
|
+
// Exactly what `walkFiles` skips: a plist in a generated directory is
|
|
536
|
+
// not the app's, and its sources are never searched.
|
|
537
|
+
.filter((one) => !isGeneratedDir(one) && isDirectory(root, `ios/${one}`))
|
|
538
|
+
.map((one) => `ios/${one}`),
|
|
539
|
+
];
|
|
540
|
+
for (const dir of plists) {
|
|
541
|
+
const plist = read(root, `${dir}/Info.plist`);
|
|
542
|
+
if (!plist) continue;
|
|
543
|
+
|
|
544
|
+
// A plist declares one delegate per scene role, and the roles after the
|
|
545
|
+
// first were never examined.
|
|
546
|
+
const activePlist = withoutXmlComments(plist);
|
|
547
|
+
for (const declared of activePlist.matchAll(
|
|
548
|
+
/<key>UISceneDelegateClassName<\/key>\s*(?:<string>([^<]*)<\/string>|<string\s*\/>)/g,
|
|
549
|
+
)) {
|
|
550
|
+
// Only `$(PRODUCT_MODULE_NAME).X` names the app's own module. A dotted
|
|
551
|
+
// name like `RNScreens.SceneDelegate` comes from a pod, which lives in
|
|
552
|
+
// a directory this deliberately does not read.
|
|
553
|
+
const value = (declared[1] ?? "").trim();
|
|
554
|
+
const qualified = /^\$[({]PRODUCT_MODULE_NAME[)}]\./.test(value);
|
|
555
|
+
// Any other module is a framework whose sources are not in this tree.
|
|
556
|
+
if (!qualified && value.includes(".")) continue;
|
|
557
|
+
// Match on the whole name; `oneLine` is for the report.
|
|
558
|
+
const name = value.split(".").pop().trim();
|
|
559
|
+
const shown = oneLine(name);
|
|
560
|
+
const line = lineAt(plist, declared.index);
|
|
561
|
+
|
|
562
|
+
if (!name) {
|
|
563
|
+
findings.push(
|
|
564
|
+
finding(
|
|
565
|
+
"ios-scene-delegate-missing",
|
|
566
|
+
"error",
|
|
567
|
+
`${dir}/Info.plist`,
|
|
568
|
+
"UISceneDelegateClassName is set to an empty class name.",
|
|
569
|
+
"Regenerate the iOS project, or remove UISceneDelegateClassName.",
|
|
570
|
+
{ line },
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const escaped = escapeRegExp(name);
|
|
577
|
+
// `@objc(Name)` renames a Swift class for UIKit, so the declaration line
|
|
578
|
+
// need not carry the name at all.
|
|
579
|
+
const defined = new RegExp(
|
|
580
|
+
`(?:\\bclass|@interface|@implementation)\\s+${escaped}\\b` +
|
|
581
|
+
`|@objc\\(\\s*${escaped}\\s*\\)`,
|
|
582
|
+
);
|
|
583
|
+
// Counting quotes to reject a name inside a string breaks on an ObjC
|
|
584
|
+
// char literal, and missing a real declaration fails a correct project.
|
|
585
|
+
// Reading a mentioned name as declared only under-reports.
|
|
586
|
+
if (declarations.some((one) => defined.test(one))) continue;
|
|
587
|
+
// A source this run could not open cannot be said to lack the class.
|
|
588
|
+
if (unreadableSource) continue;
|
|
589
|
+
|
|
590
|
+
// `$(PRODUCT_MODULE_NAME).X` says the class is in the app's own module,
|
|
591
|
+
// so its absence is proof. A bare name can also come from a linked
|
|
592
|
+
// framework, which no file in the project records.
|
|
593
|
+
findings.push(
|
|
594
|
+
finding(
|
|
595
|
+
"ios-scene-delegate-missing",
|
|
596
|
+
qualified ? "error" : "warning",
|
|
597
|
+
`${dir}/Info.plist`,
|
|
598
|
+
qualified
|
|
599
|
+
? `The Info.plist names ${shown} in the app's own module, which no source under ios/ declares.`
|
|
600
|
+
: `The Info.plist names the scene delegate ${shown}, which no source under ios/ declares.`,
|
|
601
|
+
qualified
|
|
602
|
+
? "Regenerate the iOS project, or remove UISceneDelegateClassName."
|
|
603
|
+
: "Check that a linked framework supplies it; otherwise regenerate the iOS project or remove UISceneDelegateClassName.",
|
|
604
|
+
{ line, actual: shown },
|
|
605
|
+
),
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return findings;
|
|
610
|
+
}
|