@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 +15 -0
- package/README.md +23 -1
- package/lib/node/choreCrypto.js +82 -11
- package/lib/node/deploy.js +107 -66
- package/lib/node/unzip-file.js +194 -0
- package/lib/runtime/auth/google-auth.js +16 -3
- package/lib/runtime/auth/social-auth.js +4 -2
- package/package.json +2 -2
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
|
|
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 |
|
package/lib/node/choreCrypto.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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;
|
package/lib/node/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
51
|
-
|
|
50
|
+
async function sleep (ms) {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
|
+
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
62
|
+
if (!body || typeof body.pipe !== 'function') {
|
|
63
|
+
throw new Error('Expected a streaming HTTP response body');
|
|
64
|
+
}
|
|
59
65
|
|
|
60
|
-
|
|
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,
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
204
|
+
await streamAxiosResponseToFile(response, downloadPath);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fs.rmSync(downloadPath, {force: true});
|
|
207
|
+
throw err;
|
|
175
208
|
}
|
|
176
209
|
|
|
177
|
-
return
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
if (!fileLooksLikeZip(resultZipPath)) {
|
|
368
|
+
throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
|
|
369
|
+
}
|
|
344
370
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
61
|
+
"yauzl": "^3.4.0"
|
|
62
62
|
}
|
|
63
63
|
}
|