Skip to content

Commit 762c201

Browse files
committed
fix: address 5 release blockers for export and account APIs
- Strip clientState secret from Outlook subscription in account API response - Replace blocking scryptSync with async scrypt in stream encryption - Fix duplicate ExportFolders Joi label causing Swagger conflicts - Fix export ID examples to match 24-hex-char validation pattern - Remove dead resume endpoint, Export.resume() stub, and isResumable field
1 parent d9f2e2f commit 762c201

File tree

9 files changed

+80
-283
lines changed

9 files changed

+80
-283
lines changed

lib/api-routes/account-routes.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,12 @@ async function init(args) {
825825
}
826826
}
827827

828+
if (result.outlookSubscription) {
829+
let parsed = typeof result.outlookSubscription === 'string' ? JSON.parse(result.outlookSubscription) : result.outlookSubscription;
830+
delete parsed.clientState;
831+
result.outlookSubscription = parsed;
832+
}
833+
828834
// default false
829835
for (let key of ['logs']) {
830836
result[key] = !!result[key];
@@ -1018,7 +1024,6 @@ async function init(args) {
10181024
outlookSubscription: Joi.object({
10191025
id: Joi.string().description('Microsoft Graph subscription ID'),
10201026
expirationDateTime: Joi.date().iso().description('When the subscription expires'),
1021-
clientState: Joi.string().description('Shared secret for validating webhook notifications'),
10221027
state: Joi.object({
10231028
state: Joi.string().valid('creating', 'created', 'error').description('Subscription state'),
10241029
time: Joi.number().description('Timestamp of last state change'),

lib/api-routes/export-routes.js

Lines changed: 2 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async function init(args) {
7171

7272
response: {
7373
schema: Joi.object({
74-
exportId: Joi.string().example('exp_abc123def456').description('Export job identifier'),
74+
exportId: Joi.string().example('exp_abc123def456abc123def456').description('Export job identifier'),
7575
status: Joi.string().example('queued').description('Export status'),
7676
created: Joi.date().iso().example('2024-01-15T10:30:00Z').description('When export was created')
7777
}).label('CreateExportResponse'),
@@ -153,7 +153,7 @@ async function init(args) {
153153
fileReadStream.destroy();
154154
throw Boom.serverUnavailable('Encryption secret not available for decryption');
155155
}
156-
const decryptStream = createDecryptStream(secret);
156+
const decryptStream = await createDecryptStream(secret);
157157
decryptStream.on('error', err => {
158158
request.logger.error({ msg: 'Export decryption error', exportId, err });
159159
fileReadStream.destroy();
@@ -254,59 +254,6 @@ async function init(args) {
254254
}
255255
});
256256

257-
server.route({
258-
method: 'POST',
259-
path: '/v1/account/{account}/export/{exportId}/resume',
260-
261-
async handler(request) {
262-
try {
263-
return await Export.resume(request.params.account, request.params.exportId);
264-
} catch (err) {
265-
handleError(request, err);
266-
}
267-
},
268-
269-
options: {
270-
description: 'Resume failed export',
271-
notes: 'Resumes a failed export from its last checkpoint. Only works for exports that are marked as resumable.',
272-
tags: ['api', 'Export (Beta)'],
273-
274-
auth: {
275-
strategy: 'api-token',
276-
mode: 'required'
277-
},
278-
cors: CORS_CONFIG,
279-
280-
validate: {
281-
options: {
282-
stripUnknown: false,
283-
abortEarly: false,
284-
convert: true
285-
},
286-
failAction,
287-
288-
params: Joi.object({
289-
account: accountIdSchema.required(),
290-
exportId: exportIdSchema
291-
}).label('ResumeExportParams')
292-
},
293-
294-
response: {
295-
schema: Joi.object({
296-
exportId: Joi.string().example('exp_abc123def456').description('Export job identifier'),
297-
status: Joi.string().example('queued').description('Export status'),
298-
resumed: Joi.boolean().example(true).description('True if export was resumed'),
299-
progress: Joi.object({
300-
messagesExported: Joi.number().integer().example(500).description('Messages already exported'),
301-
messagesQueued: Joi.number().integer().example(1500).description('Total messages queued'),
302-
messagesSkipped: Joi.number().integer().example(5).description('Messages skipped')
303-
}).label('ResumeExportProgress')
304-
}).label('ResumeExportResponse'),
305-
failAction: 'log'
306-
}
307-
}
308-
});
309-
310257
server.route({
311258
method: 'GET',
312259
path: '/v1/account/{account}/exports',

lib/export.js

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,6 @@ class Export {
220220
error: data.error || null
221221
};
222222

223-
if (data.status === 'failed') {
224-
result.isResumable = false;
225-
}
226-
227223
return result;
228224
}
229225

@@ -389,8 +385,7 @@ class Export {
389385
.multi()
390386
.hmset(exportKey, {
391387
status: 'failed',
392-
error: error || 'Unknown error',
393-
isResumable: '0'
388+
error: error || 'Unknown error'
394389
})
395390
.del(queueKey)
396391
.srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`)
@@ -399,10 +394,6 @@ class Export {
399394
logger.error({ msg: 'Export failed', account, exportId, error });
400395
}
401396

402-
static async resume(account, exportId) {
403-
throw createError('Export resume is not currently supported', 'ExportNotResumable', 501);
404-
}
405-
406397
static async markInterruptedAsFailed() {
407398
const activeExports = await redis.smembers(ACTIVE_EXPORTS_KEY);
408399

lib/routes-ui.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ function applyRoutes(server, call) {
10461046
throw Boom.serverUnavailable('Encryption secret not available for decryption');
10471047
}
10481048
const { createDecryptStream } = require('./stream-encrypt');
1049-
const decryptStream = createDecryptStream(secret);
1049+
const decryptStream = await createDecryptStream(secret);
10501050
decryptStream.on('error', err => {
10511051
request.logger.error({ msg: 'Export decryption error', exportId, err });
10521052
fileReadStream.destroy();

lib/schemas.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,25 +1764,24 @@ const exportProgressSchema = Joi.object({
17641764
}).label('ExportProgress');
17651765

17661766
const exportStatusSchema = Joi.object({
1767-
exportId: Joi.string().example('exp_abc123def456').description('Export job identifier'),
1767+
exportId: Joi.string().example('exp_abc123def456abc123def456').description('Export job identifier'),
17681768
status: Joi.string()
17691769
.valid('queued', 'processing', 'completed', 'failed', 'cancelled')
17701770
.example('processing')
17711771
.description('Export status')
17721772
.label('ExportStatusValue'),
17731773
phase: Joi.string().valid('indexing', 'exporting', 'complete').example('indexing').description('Current export phase').label('ExportPhase'),
1774-
folders: Joi.array().items(Joi.string().label('ExportFolderItem')).description('Folders being exported').label('ExportFolders'),
1774+
folders: Joi.array().items(Joi.string().label('ExportFolderItem')).description('Folders being exported').label('ExportStatusFolders'),
17751775
startDate: Joi.date().iso().example('2024-01-01T00:00:00Z').description('Export start date filter'),
17761776
endDate: Joi.date().iso().example('2024-12-31T23:59:59Z').description('Export end date filter'),
17771777
progress: exportProgressSchema,
17781778
created: Joi.date().iso().example('2024-01-15T10:30:00Z').description('When export was created'),
17791779
expiresAt: Joi.date().iso().example('2024-01-16T10:30:00Z').description('When export file expires'),
1780-
error: Joi.string().allow(null).description('Error message if export failed'),
1781-
isResumable: Joi.boolean().description('Whether the failed export can be resumed (only present when status is failed)')
1780+
error: Joi.string().allow(null).description('Error message if export failed')
17821781
}).label('ExportStatus');
17831782

17841783
const exportListEntrySchema = Joi.object({
1785-
exportId: Joi.string().example('exp_abc123def456').description('Export job identifier'),
1784+
exportId: Joi.string().example('exp_abc123def456abc123def456').description('Export job identifier'),
17861785
status: Joi.string()
17871786
.valid('queued', 'processing', 'completed', 'failed', 'cancelled')
17881787
.example('completed')

lib/stream-encrypt.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@ const IV_LENGTH = 12;
1010
const AUTH_TAG_LENGTH = 16;
1111
const SALT_LENGTH = 16;
1212
const HEADER_SIZE = MAGIC.length + 4 + 4 + SALT_LENGTH;
13-
14-
function deriveKey(password, salt) {
15-
return crypto.scryptSync(password, salt, 32);
13+
const MAX_CHUNK_SIZE = CHUNK_SIZE * 4;
14+
15+
function deriveKeyAsync(password, salt) {
16+
return new Promise((resolve, reject) => {
17+
crypto.scrypt(password, salt, 32, (err, key) => {
18+
if (err) reject(err);
19+
else resolve(key);
20+
});
21+
});
1622
}
1723

1824
class EncryptStream extends Transform {
19-
constructor(secret) {
25+
constructor({ key, salt }) {
2026
super();
21-
this.secret = secret;
22-
this.salt = crypto.randomBytes(SALT_LENGTH);
23-
this.key = deriveKey(secret, this.salt);
27+
this.key = key;
28+
this.salt = salt;
2429
this.buffer = Buffer.alloc(0);
2530
this.headerWritten = false;
2631
}
@@ -115,10 +120,12 @@ class DecryptStream extends Transform {
115120
offset += 4;
116121

117122
this.chunkSize = this.buffer.readUInt32LE(offset);
123+
if (this.chunkSize > MAX_CHUNK_SIZE) {
124+
throw new Error('Invalid chunk size in header');
125+
}
118126
offset += 4;
119127

120-
const salt = this.buffer.subarray(offset, offset + SALT_LENGTH);
121-
this.key = deriveKey(this.secret, salt);
128+
this.salt = this.buffer.subarray(offset, offset + SALT_LENGTH);
122129

123130
this.buffer = this.buffer.subarray(HEADER_SIZE);
124131
this.headerParsed = true;
@@ -134,7 +141,7 @@ class DecryptStream extends Transform {
134141

135142
const iv = this.buffer.subarray(0, IV_LENGTH);
136143
const encryptedLength = this.buffer.readUInt32LE(IV_LENGTH);
137-
if (encryptedLength > CHUNK_SIZE + 256) {
144+
if (encryptedLength > this.chunkSize + 256) {
138145
throw new Error('Invalid encrypted chunk length');
139146
}
140147
const totalChunkSize = minChunkHeader + encryptedLength + AUTH_TAG_LENGTH;
@@ -164,7 +171,7 @@ class DecryptStream extends Transform {
164171
return decrypted;
165172
}
166173

167-
_transform(data, encoding, callback) {
174+
async _transform(data, encoding, callback) {
168175
try {
169176
this.buffer = Buffer.concat([this.buffer, data]);
170177

@@ -173,6 +180,8 @@ class DecryptStream extends Transform {
173180
callback();
174181
return;
175182
}
183+
this.key = await deriveKeyAsync(this.secret, this.salt);
184+
this.secret = null;
176185
}
177186

178187
let decrypted;
@@ -210,14 +219,16 @@ class DecryptStream extends Transform {
210219
}
211220
}
212221

213-
function createEncryptStream(secret) {
222+
async function createEncryptStream(secret) {
214223
if (!secret) {
215224
throw new Error('Encryption secret is required');
216225
}
217-
return new EncryptStream(secret);
226+
const salt = crypto.randomBytes(SALT_LENGTH);
227+
const key = await deriveKeyAsync(secret, salt);
228+
return new EncryptStream({ key, salt });
218229
}
219230

220-
function createDecryptStream(secret) {
231+
async function createDecryptStream(secret) {
221232
if (!secret) {
222233
throw new Error('Decryption secret is required');
223234
}

0 commit comments

Comments
 (0)