@makefully/adaptfully 5.0.0 → 5.0.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 5.0.2 — 2026-10-05
6
+
7
+ ### Fixed
8
+
9
+ - Wrapfully chore downloads stream to a temp `.zip` on disk and extract with **yauzl** (lazy file streams) instead of buffering the whole Electron/Steam artifact in RAM via `axios` `arraybuffer` + `unzip-stream` MatcherStream (Node heap OOM despite free system RAM).
10
+ - Encrypted result envelopes decrypt with streamed AES-GCM (`aesDecryptFile`) to a plaintext zip file before extract.
11
+
12
+ ## 5.0.1 — 2026-09-17
13
+
14
+ ### Changed
15
+
16
+ - **`google-auth`** requires `config.googleClientId` (no built-in Entanglement client id default).
17
+ - **`social-auth`** no longer falls back to `config.googleClientId` for Capgo `webClientId` — use `platforms.<name>.socialLogin.google.webClientId` only.
18
+ - Docs: browser GIS vs Capacitor Google client ids are documented as separate fan-out surfaces.
19
+
5
20
  ## 5.0.0 — 2026-09-11
6
21
 
7
22
  ### Changed
package/README.md CHANGED
@@ -136,7 +136,7 @@ Games should call `adaptfully.has('analytics')` before `get` during rollout if o
136
136
 
137
137
  | Plugin key | Registration | Runtime |
138
138
  |------------|--------------|---------|
139
- | `google-auth` | `adaptfully.register('auth', adaptfully.auth.Google)` | Web, Android, iOS |
139
+ | `google-auth` | `adaptfully.register('auth', adaptfully.auth.Google)` | Web (Google Identity Services) |
140
140
  | `steam-auth` | `adaptfully.register('auth', adaptfully.auth.Steam)` | Steam / Electron (via [steamworks.js](https://github.com/ceifa/steamworks.js)) |
141
141
  | `social-auth` | `adaptfully.register('auth', adaptfully.auth.Social)` | Capacitor (via [@capgo/capacitor-social-login](https://github.com/Cap-go/capacitor-social-login)) |
142
142
  | `dev-auth` | `adaptfully.register('auth', adaptfully.auth.Dev)` | Local / testing |
@@ -188,6 +188,13 @@ Android/iOS Wrapfully builds use the **`capacitor`** builder family. Cordova/Pho
188
188
 
189
189
  **`social-auth` requires `packager: "capacitor"`** and `platforms.<name>.socialLogin` (Capgo provider client IDs). Wrapfully installs `@capgo/capacitor-social-login` when building.
190
190
 
191
+ Google client IDs are **not** interchangeable across plugins:
192
+
193
+ | Surface | Config | Notes |
194
+ |---------|--------|--------|
195
+ | Browser GIS (`google-auth`) | `config.googleClientId` (via your config registration / bridge) | OAuth **Web** client whose authorized JavaScript origins include the site origin |
196
+ | Capacitor Capgo (`social-auth`) | `platforms.<name>.socialLogin.google.webClientId` | Server / web client id for native Google Sign-In — do **not** put this in `googleClientId` |
197
+
191
198
  ```json
192
199
  {
193
200
  "config": {
@@ -220,6 +227,19 @@ Android/iOS Wrapfully builds use the **`capacitor`** builder family. Cordova/Pho
220
227
  }
221
228
  ```
222
229
 
230
+ #### Google auth (`google-auth`)
231
+
232
+ Browser-only plugin using [Google Identity Services](https://developers.google.com/identity/oauth2/web/guides/overview) (`accounts.google.com/gsi/client`). Requires `config.googleClientId` — there is no built-in default. GIS authorized origins are **per OAuth client**; reusing `socialLogin.google.webClientId` here is a common failure mode when mobile and web need different clients.
233
+
234
+ Optional config keys:
235
+
236
+ | Key | Default | Purpose |
237
+ |-----|---------|---------|
238
+ | `googleClientId` | *(required)* | Browser GIS OAuth client id |
239
+ | `googleTokenKey` | `adaptfully_google_token` | `sessionStorage` key for the access token |
240
+ | `googleScopes` | `openid email profile` | OAuth scopes |
241
+ | `autoLoginStorageKey` | `lastLoggedIn` | Storage key written with the user id on login |
242
+
223
243
  #### Steam auth (`steam-auth`)
224
244
 
225
245
  When `steam-auth` is registered on an **`electron`** platform, Adaptfully prebuild writes:
@@ -242,6 +262,8 @@ The runtime plugin calls Capgo `SocialLogin.initialize` / `login` / `logout` / `
242
262
 
243
263
  **Apple-only iOS** (`providers.google: false`, `providers.apple: true`) is supported — Google `webClientId` is not required. On iOS, if `apple.clientId` is omitted, Adaptfully defaults it to the platform `packageName` (Capgo uses this as a plugin label on native iOS, not an Apple Services ID). On Android, Apple Sign-In still requires `apple.clientId`. At runtime, an empty Apple `clientId` also falls back to `window.gameConfig.packageName`.
244
264
 
265
+ Google Sign-In on Capacitor reads **`socialLogin.google.webClientId` only** (via `social-login-config.js`). It does not fall back to `config.googleClientId`.
266
+
245
267
  Optional config keys:
246
268
 
247
269
  | Key | Default | Purpose |
@@ -2,14 +2,14 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import {Readable, PassThrough} from 'node:stream';
5
+ import {PassThrough} from 'node:stream';
6
6
  import {pipeline} from 'node:stream/promises';
7
7
  import archiver from 'archiver';
8
- import unzipper from 'unzip-stream';
9
8
  import {
10
9
  WRAPFULLY_DEFAULT_KID,
11
10
  WRAPFULLY_DEFAULT_PUBLIC_KEY,
12
11
  } from './wrapfullyDefaultPublicKey.js';
12
+ import {extractZipFile, zipHasEntries} from './unzip-file.js';
13
13
 
14
14
  const
15
15
  ENVELOPE_DATA = 'wrapfully.enc',
@@ -40,6 +40,38 @@ function aesDecrypt (key, blob) {
40
40
  return Buffer.concat([decipher.update(data), decipher.final()]);
41
41
  }
42
42
 
43
+ /**
44
+ * Stream-decrypt an AES-256-GCM blob (iv||tag||ciphertext) from disk to disk.
45
+ * @param {Buffer} key
46
+ * @param {string} inPath
47
+ * @param {string} outPath
48
+ */
49
+ export async function aesDecryptFile (key, inPath, outPath) {
50
+ const fh = await fs.promises.open(inPath, 'r');
51
+ const header = Buffer.alloc(28);
52
+ let bytesRead = 0;
53
+
54
+ try {
55
+ ({bytesRead} = await fh.read(header, 0, 28, 0));
56
+ } finally {
57
+ await fh.close();
58
+ }
59
+ if (bytesRead < 28) {
60
+ throw new Error('Encrypted payload too short for AES-GCM header');
61
+ }
62
+
63
+ const iv = header.subarray(0, 12);
64
+ const tag = header.subarray(12, 28);
65
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
66
+
67
+ decipher.setAuthTag(tag);
68
+ await pipeline(
69
+ fs.createReadStream(inPath, {start: 28}),
70
+ decipher,
71
+ fs.createWriteStream(outPath),
72
+ );
73
+ }
74
+
43
75
  function wrapKey (aesKey, publicKeyPem) {
44
76
  return crypto.publicEncrypt(
45
77
  {
@@ -152,32 +184,71 @@ export async function createEnvelopeFromInnerZip (innerZip, {publicKeyPem, kid,
152
184
  }
153
185
 
154
186
  /**
155
- * Decrypt a completed Yap result envelope into a plaintext zip Buffer.
187
+ * Decrypt a completed Yap result envelope zip on disk into a plaintext zip file.
188
+ * Avoids holding the Electron/Steam artifact in RAM.
189
+ * @param {string} envelopeZipPath
190
+ * @param {string} resultPrivPem
191
+ * @param {string} outZipPath
192
+ * @returns {Promise<'envelope'|'passthrough'>} passthrough when file is already a plain zip
156
193
  */
157
- export async function decryptResultEnvelope (resultZipBuf, resultPrivPem) {
194
+ export async function decryptResultEnvelopeToFile (envelopeZipPath, resultPrivPem, outZipPath) {
158
195
  if (!resultPrivPem) {
159
196
  throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
160
197
  }
161
198
 
199
+ const isEnvelope = await zipHasEntries(envelopeZipPath, [RESULT_DATA, RESULT_KEY]);
200
+
201
+ if (!isEnvelope) {
202
+ // Not an envelope — copy as-is (streamed); never extract the whole artifact.
203
+ await pipeline(
204
+ fs.createReadStream(envelopeZipPath),
205
+ fs.createWriteStream(outZipPath),
206
+ );
207
+
208
+ return 'passthrough';
209
+ }
210
+
162
211
  const tmp = path.join(os.tmpdir(), `adaptfully-result-${Date.now().toString(16)}`);
163
212
 
164
213
  fs.mkdirSync(tmp, {recursive: true});
165
214
  try {
166
- await pipeline(Readable.from(resultZipBuf), unzipper.Extract({path: tmp}));
167
- if (!fs.existsSync(path.join(tmp, RESULT_DATA))) {
168
- // Not an envelope — return as-is
169
- return resultZipBuf;
170
- }
171
- const ciphertext = fs.readFileSync(path.join(tmp, RESULT_DATA));
215
+ // Envelope zips are small (AES blob + wrapped key).
216
+ await extractZipFile(envelopeZipPath, tmp);
172
217
  const wrapped = fs.readFileSync(path.join(tmp, RESULT_KEY));
173
218
  const aesKey = unwrapKey(wrapped, resultPrivPem);
174
219
 
175
- return aesDecrypt(aesKey, ciphertext);
220
+ await aesDecryptFile(aesKey, path.join(tmp, RESULT_DATA), outZipPath);
221
+
222
+ return 'envelope';
176
223
  } finally {
177
224
  fs.rmSync(tmp, {recursive: true, force: true});
178
225
  }
179
226
  }
180
227
 
228
+ /**
229
+ * Decrypt a completed Yap result envelope into a plaintext zip Buffer.
230
+ * Prefer {@link decryptResultEnvelopeToFile} for large Electron artifacts.
231
+ */
232
+ export async function decryptResultEnvelope (resultZipBuf, resultPrivPem) {
233
+ if (!resultPrivPem) {
234
+ throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
235
+ }
236
+
237
+ const stamp = Date.now().toString(16);
238
+ const envelopePath = path.join(os.tmpdir(), `adaptfully-env-${stamp}.zip`);
239
+ const outPath = path.join(os.tmpdir(), `adaptfully-plain-${stamp}.zip`);
240
+
241
+ try {
242
+ fs.writeFileSync(envelopePath, resultZipBuf);
243
+ await decryptResultEnvelopeToFile(envelopePath, resultPrivPem, outPath);
244
+
245
+ return fs.readFileSync(outPath);
246
+ } finally {
247
+ fs.rmSync(envelopePath, {force: true});
248
+ fs.rmSync(outPath, {force: true});
249
+ }
250
+ }
251
+
181
252
  export function isResultEnvelopeBuffer (buf) {
182
253
  // Weak check — PK zip; decryptResultEnvelope falls back if members missing
183
254
  return Buffer.isBuffer(buf) && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b;
@@ -1,19 +1,19 @@
1
1
  import axios from 'axios';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
- import {Readable} from 'node:stream';
5
5
  import {pipeline} from 'node:stream/promises';
6
- import unzipper from 'unzip-stream';
7
6
  import {createDeployArchive, createReleaseArchive, createSourceArchive} from './archive.js';
8
7
  import {clearStaleBuildExtract} from './artifacts.js';
9
8
  import {listHtmlFilesRecursive} from './fs-utils.js';
10
9
  import {printBuildReport} from './report.js';
11
10
  import {
12
11
  createEnvelopeFromInnerZip,
13
- decryptResultEnvelope,
12
+ decryptResultEnvelopeToFile,
14
13
  generateResultKeyPair,
15
14
  resolveEncryptPublicKey,
16
15
  } from './choreCrypto.js';
16
+ import {extractZipFile, fileLooksLikeZip} from './unzip-file.js';
17
17
 
18
18
  const POLL_MS = 5000;
19
19
 
@@ -47,22 +47,28 @@ export function buildWrapfullyJobConfig (stage, family, gameId, platformKey, dep
47
47
  return job;
48
48
  }
49
49
 
50
- async function streamToBuffer (stream) {
51
- const chunks = [];
50
+ async function sleep (ms) {
51
+ await new Promise((resolve) => setTimeout(resolve, ms));
52
+ }
52
53
 
53
- for await (const chunk of stream) {
54
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
55
- }
54
+ /**
55
+ * Stream an axios response body to a file (does not buffer the whole body in RAM).
56
+ * @param {import('axios').AxiosResponse} response
57
+ * @param {string} outPath
58
+ */
59
+ async function streamAxiosResponseToFile (response, outPath) {
60
+ const body = response.data;
56
61
 
57
- return Buffer.concat(chunks);
58
- }
62
+ if (!body || typeof body.pipe !== 'function') {
63
+ throw new Error('Expected a streaming HTTP response body');
64
+ }
59
65
 
60
- async function sleep (ms) {
61
- await new Promise((resolve) => setTimeout(resolve, ms));
66
+ await pipeline(body, fs.createWriteStream(outPath));
62
67
  }
63
68
 
64
69
  /**
65
- * Submit zip to Showfully Yap, poll until complete, return result zip Buffer.
70
+ * Submit zip to Showfully Yap, poll until complete, stream result zip to disk.
71
+ * @returns {Promise<string>} absolute path to the downloaded zip (caller deletes)
66
72
  */
67
73
  export async function submitWrapfullyChore ({
68
74
  server,
@@ -159,22 +165,49 @@ export async function submitWrapfullyChore ({
159
165
 
160
166
  log(`adaptfully: downloading chore ${choreId}`);
161
167
  const downloadUrl = `${base}yap/wrapfully/${encodeURIComponent(choreId)}`;
162
- const {data, headers: resHeaders} = await axios.get(downloadUrl, {
163
- headers: {Authorization: `Bearer ${accessToken}`},
164
- responseType: 'arraybuffer',
165
- maxContentLength: Infinity,
166
- validateStatus: () => true,
167
- });
168
+ const downloadPath = path.join(
169
+ os.tmpdir(),
170
+ `adaptfully-chore-${choreId}-${Date.now().toString(16)}.zip`,
171
+ );
168
172
 
169
- const ctype = resHeaders['content-type'] || '';
173
+ try {
174
+ const response = await axios.get(downloadUrl, {
175
+ headers: {Authorization: `Bearer ${accessToken}`},
176
+ responseType: 'stream',
177
+ maxContentLength: Infinity,
178
+ maxBodyLength: Infinity,
179
+ validateStatus: () => true,
180
+ });
181
+ const ctype = response.headers['content-type'] || '';
182
+
183
+ if (response.status >= 400 || ctype.includes('application/json')) {
184
+ const chunks = [];
185
+
186
+ for await (const chunk of response.data) {
187
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
188
+ }
189
+ const text = Buffer.concat(chunks).toString('utf8');
190
+ let message = 'Chore download failed';
191
+
192
+ try {
193
+ const json = JSON.parse(text);
170
194
 
171
- if (ctype.includes('application/json')) {
172
- const json = JSON.parse(Buffer.from(data).toString('utf8'));
195
+ message = json.errors?.[0] || message;
196
+ } catch {
197
+ if (text.trim()) {
198
+ message = text.slice(0, 500);
199
+ }
200
+ }
201
+ throw new Error(message);
202
+ }
173
203
 
174
- throw new Error(json.errors?.[0] || 'Chore download returned JSON');
204
+ await streamAxiosResponseToFile(response, downloadPath);
205
+ } catch (err) {
206
+ fs.rmSync(downloadPath, {force: true});
207
+ throw err;
175
208
  }
176
209
 
177
- return Buffer.from(data);
210
+ return downloadPath;
178
211
  }
179
212
 
180
213
  /**
@@ -252,29 +285,17 @@ export async function send (
252
285
  log(`adaptfully: sending ${htmlFiles.length} HTML file(s) from ${path.resolve(deployFolder)}`);
253
286
  }
254
287
 
255
- // Append wrapfully.json routing into the archive by rebuilding into a buffer.
288
+ // Append wrapfully.json by extracting the upload archive to disk, then rezipping.
256
289
  const job = buildWrapfullyJobConfig(stage, family, gameId, platformKey, options.deploymentKey);
257
290
  const innerChunks = [];
258
-
259
- // Re-pack: read archiveStream into zip, add wrapfully.json
260
- // Archiver streams aren't easily mutable — collect then use yazl-free approach:
261
- // pipe archive to buffer, unzip to tmp, add file, rezip is heavy.
262
- // Simpler: createSourceArchive already finalized as stream — append via second archiver
263
- // that copies entries is complex. Instead append wrapfully.json by concatenating
264
- // a sidecar zip is wrong. Best: write wrapfully.json into deploy folder meta before
265
- // archive... but that mutates disk.
266
- //
267
- // Practical approach matching Dutifully: build archive, buffer it, then use a new
268
- // archiver that includes the buffer as... no that's nested.
269
- //
270
- // Use jszip-like: buffer the created archive by consuming stream, then use
271
- // adm-zip equivalent — we have unzipper. Extract to tmp, write wrapfully.json, rezip.
272
-
273
- const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${Date.now().toString(16)}`);
291
+ const stamp = Date.now().toString(16);
292
+ const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${stamp}`);
293
+ const uploadZipPath = path.join(outputRoot, `.adaptfully-upload-${stamp}.zip`);
274
294
 
275
295
  fs.mkdirSync(tmpRoot, {recursive: true});
276
296
  try {
277
- await pipeline(archiveStream, unzipper.Extract({path: tmpRoot, concurrency: 1}));
297
+ await pipeline(archiveStream, fs.createWriteStream(uploadZipPath));
298
+ await extractZipFile(uploadZipPath, tmpRoot);
278
299
  fs.writeFileSync(path.join(tmpRoot, 'wrapfully.json'), JSON.stringify(job, null, 2));
279
300
 
280
301
  const {default: archiver} = await import('archiver');
@@ -296,6 +317,7 @@ export async function send (
296
317
  await done;
297
318
  } finally {
298
319
  fs.rmSync(tmpRoot, {recursive: true, force: true});
320
+ fs.rmSync(uploadZipPath, {force: true});
299
321
  }
300
322
 
301
323
  let uploadBuffer = Buffer.concat(innerChunks);
@@ -318,39 +340,58 @@ export async function send (
318
340
  log(`adaptfully: encrypting chore with kid "${kid}"`);
319
341
  }
320
342
 
321
- let resultBuffer = await submitWrapfullyChore({
343
+ const downloadPath = await submitWrapfullyChore({
322
344
  server,
323
345
  accessToken,
324
346
  zipBuffer: uploadBuffer,
325
347
  log,
326
348
  });
349
+ const plainZipPath = path.join(
350
+ os.tmpdir(),
351
+ `adaptfully-plain-${path.basename(downloadPath)}`,
352
+ );
353
+ let resultZipPath = downloadPath;
327
354
 
328
- if (resultPriv) {
329
- try {
330
- resultBuffer = await decryptResultEnvelope(resultBuffer, resultPriv);
331
- } catch (err) {
332
- throw new Error(`Failed to decrypt Wrapfully result envelope: ${err.message || err}`);
355
+ try {
356
+ if (resultPriv) {
357
+ try {
358
+ await decryptResultEnvelopeToFile(downloadPath, resultPriv, plainZipPath);
359
+ resultZipPath = plainZipPath;
360
+ } catch (err) {
361
+ throw new Error(
362
+ `Failed to decrypt Wrapfully result envelope: ${err.message || err}`
363
+ );
364
+ }
333
365
  }
334
- }
335
-
336
- if (!Buffer.isBuffer(resultBuffer) || resultBuffer.length < 4
337
- || resultBuffer[0] !== 0x50 || resultBuffer[1] !== 0x4b) {
338
- throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
339
- }
340
366
 
341
- const destination = mode === 'extract'
342
- ? unzipper.Extract({path: `${extractRoot}/`, concurrency: 1})
343
- : fs.createWriteStream(`${extractRoot}/${pkg.name}-${pkg.version}-${stage}-${family}.zip`);
367
+ if (!fileLooksLikeZip(resultZipPath)) {
368
+ throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
369
+ }
344
370
 
345
- try {
346
- await pipeline(Readable.from(resultBuffer), destination);
347
- } catch (err) {
348
- throw new Error(
349
- `Failed to unpack chore result (${resultBuffer.length} bytes): ${err.message || err}`
350
- );
351
- }
371
+ if (mode === 'extract') {
372
+ try {
373
+ await extractZipFile(resultZipPath, extractRoot);
374
+ } catch (err) {
375
+ const size = fs.statSync(resultZipPath).size;
352
376
 
353
- if (mode === 'extract') {
354
- printBuildReport(`${stage}-${family}`, pkg, { extractRoot });
377
+ throw new Error(
378
+ `Failed to unpack chore result (${size} bytes): ${err.message || err}`
379
+ );
380
+ }
381
+ printBuildReport(`${stage}-${family}`, pkg, {extractRoot});
382
+ } else {
383
+ const destZip = path.join(
384
+ extractRoot,
385
+ `${pkg.name}-${pkg.version}-${stage}-${family}.zip`,
386
+ );
387
+
388
+ await pipeline(
389
+ fs.createReadStream(resultZipPath),
390
+ fs.createWriteStream(destZip),
391
+ );
392
+ }
393
+ } finally {
394
+ fs.rmSync(downloadPath, {force: true});
395
+ fs.rmSync(plainZipPath, {force: true});
355
396
  }
356
397
  }
@@ -0,0 +1,194 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {pipeline} from 'node:stream/promises';
4
+ import yauzl from 'yauzl';
5
+
6
+ /**
7
+ * Resolve entry path under destDir; reject zip-slip escapes.
8
+ * @param {string} destDir
9
+ * @param {string} fileName
10
+ */
11
+ function safeEntryPath (destDir, fileName) {
12
+ const root = path.resolve(destDir);
13
+ const target = path.resolve(root, fileName);
14
+
15
+ if (target !== root && !target.startsWith(root + path.sep)) {
16
+ throw new Error(`Refusing zip entry outside extract root: ${fileName}`);
17
+ }
18
+
19
+ return target;
20
+ }
21
+
22
+ /**
23
+ * Whether a zip on disk contains every listed entry name (forward-slash paths).
24
+ * @param {string} zipPath
25
+ * @param {string[]} entryNames
26
+ * @returns {Promise<boolean>}
27
+ */
28
+ export function zipHasEntries (zipPath, entryNames) {
29
+ const needed = new Set(entryNames.map((n) => String(n).replace(/\\/g, '/')));
30
+
31
+ return new Promise((resolve, reject) => {
32
+ yauzl.open(zipPath, {lazyEntries: true, autoClose: true}, (openErr, zipfile) => {
33
+ if (openErr) {
34
+ reject(openErr);
35
+
36
+ return;
37
+ }
38
+
39
+ let settled = false;
40
+ const found = new Set();
41
+ const fail = (err) => {
42
+ if (!settled) {
43
+ settled = true;
44
+ reject(err);
45
+ }
46
+ };
47
+ const finish = (value) => {
48
+ if (!settled) {
49
+ settled = true;
50
+ resolve(value);
51
+ }
52
+ };
53
+
54
+ zipfile.on('error', fail);
55
+ zipfile.on('end', () => {
56
+ finish([...needed].every((n) => found.has(n)));
57
+ });
58
+ zipfile.readEntry();
59
+
60
+ zipfile.on('entry', (entry) => {
61
+ const name = String(entry.fileName || '').replace(/\\/g, '/');
62
+
63
+ if (needed.has(name)) {
64
+ found.add(name);
65
+ if (found.size === needed.size) {
66
+ // Close early once all required members are present.
67
+ try {
68
+ zipfile.close();
69
+ } catch {
70
+ // ignore
71
+ }
72
+ finish(true);
73
+
74
+ return;
75
+ }
76
+ }
77
+ zipfile.readEntry();
78
+ });
79
+ });
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Extract a zip on disk into destDir without holding the archive in RAM.
85
+ * Uses yauzl lazy entries + per-file streams.
86
+ * @param {string} zipPath
87
+ * @param {string} destDir
88
+ * @returns {Promise<void>}
89
+ */
90
+ export function extractZipFile (zipPath, destDir) {
91
+ fs.mkdirSync(destDir, {recursive: true});
92
+
93
+ return new Promise((resolve, reject) => {
94
+ yauzl.open(zipPath, {lazyEntries: true, autoClose: true}, (openErr, zipfile) => {
95
+ if (openErr) {
96
+ reject(openErr);
97
+
98
+ return;
99
+ }
100
+
101
+ let settled = false;
102
+ const fail = (err) => {
103
+ if (!settled) {
104
+ settled = true;
105
+ reject(err);
106
+ }
107
+ };
108
+ const done = () => {
109
+ if (!settled) {
110
+ settled = true;
111
+ resolve();
112
+ }
113
+ };
114
+
115
+ zipfile.on('error', fail);
116
+ zipfile.on('end', done);
117
+ zipfile.readEntry();
118
+
119
+ zipfile.on('entry', (entry) => {
120
+ const name = String(entry.fileName || '').replace(/\\/g, '/');
121
+
122
+ if (!name || name.includes('\0')) {
123
+ fail(new Error(`Invalid zip entry name: ${entry.fileName}`));
124
+
125
+ return;
126
+ }
127
+
128
+ let destPath;
129
+
130
+ try {
131
+ destPath = safeEntryPath(destDir, name);
132
+ } catch (err) {
133
+ fail(err);
134
+
135
+ return;
136
+ }
137
+
138
+ if (/\/$/u.test(name)) {
139
+ try {
140
+ fs.mkdirSync(destPath, {recursive: true});
141
+ } catch (err) {
142
+ fail(err);
143
+
144
+ return;
145
+ }
146
+ zipfile.readEntry();
147
+
148
+ return;
149
+ }
150
+
151
+ try {
152
+ fs.mkdirSync(path.dirname(destPath), {recursive: true});
153
+ } catch (err) {
154
+ fail(err);
155
+
156
+ return;
157
+ }
158
+
159
+ zipfile.openReadStream(entry, (streamErr, readStream) => {
160
+ if (streamErr) {
161
+ fail(streamErr);
162
+
163
+ return;
164
+ }
165
+ const writeStream = fs.createWriteStream(destPath);
166
+
167
+ pipeline(readStream, writeStream)
168
+ .then(() => {
169
+ zipfile.readEntry();
170
+ })
171
+ .catch(fail);
172
+ });
173
+ });
174
+ });
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Peek whether a file starts with the ZIP local-file magic (`PK`).
180
+ * @param {string} filePath
181
+ * @returns {boolean}
182
+ */
183
+ export function fileLooksLikeZip (filePath) {
184
+ const fd = fs.openSync(filePath, 'r');
185
+
186
+ try {
187
+ const buf = Buffer.alloc(4);
188
+ const n = fs.readSync(fd, buf, 0, 4, 0);
189
+
190
+ return n >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
191
+ } finally {
192
+ fs.closeSync(fd);
193
+ }
194
+ }
@@ -3,7 +3,6 @@
3
3
  (function registerGoogleAuth(ns) {
4
4
  const { configValue, getStorage } = ns.auth.helpers;
5
5
 
6
- const DEFAULT_CLIENT_ID = '225754014403.apps.googleusercontent.com';
7
6
  const DEFAULT_SCOPES = 'openid email profile';
8
7
  const DEFAULT_TOKEN_KEY = 'adaptfully_google_token';
9
8
  const DEFAULT_AUTO_LOGIN_KEY = 'lastLoggedIn';
@@ -33,8 +32,13 @@
33
32
  return configValue('googleTokenKey', DEFAULT_TOKEN_KEY);
34
33
  }
35
34
 
35
+ /**
36
+ * Browser GIS OAuth client id only (`config.googleClientId`).
37
+ * Capacitor / Capgo builds use `platforms.<name>.socialLogin.google.webClientId` instead.
38
+ */
36
39
  #clientId() {
37
- return configValue('googleClientId', DEFAULT_CLIENT_ID);
40
+ const clientId = configValue('googleClientId', '');
41
+ return typeof clientId === 'string' ? clientId.trim() : '';
38
42
  }
39
43
 
40
44
  #scopes() {
@@ -47,8 +51,17 @@
47
51
 
48
52
  whenReady(done) {
49
53
  const setup = () => {
54
+ const clientId = this.#clientId();
55
+ if (!clientId) {
56
+ console.error(
57
+ 'google-auth: config.googleClientId is required '
58
+ + '(browser GIS OAuth client; do not reuse socialLogin.google.webClientId).',
59
+ );
60
+ done({ error: 'google-auth: config.googleClientId is required' });
61
+ return;
62
+ }
50
63
  this.tokenClient = google.accounts.oauth2.initTokenClient({
51
- client_id: this.#clientId(),
64
+ client_id: clientId,
52
65
  scope: this.#scopes(),
53
66
  callback: () => {},
54
67
  });
@@ -107,9 +107,11 @@
107
107
  const options = {};
108
108
 
109
109
  if (config.providers?.google !== false) {
110
+ // Capgo webClientId comes only from platforms.<name>.socialLogin
111
+ // (written to __ADAPTFULLY_SOCIAL_LOGIN__). Do not fall back to
112
+ // config.googleClientId — that key is for browser google-auth / GIS.
110
113
  options.google = {
111
- webClientId: config.google?.webClientId
112
- || configValue('googleClientId', ''),
114
+ webClientId: config.google?.webClientId || '',
113
115
  iOSClientId: config.google?.iOSClientId,
114
116
  iOSServerClientId: config.google?.iOSServerClientId
115
117
  || config.google?.webClientId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",
@@ -58,6 +58,6 @@
58
58
  "dependencies": {
59
59
  "archiver": "^7.0.1",
60
60
  "axios": "^1.7.9",
61
- "unzip-stream": "^0.3.4"
61
+ "yauzl": "^3.4.0"
62
62
  }
63
63
  }