@vue-skuilder/express 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Per-user database provisioning daemon.
3
+ *
4
+ * Replaces CouchDB's built-in couch_peruser Erlang module, which has a
5
+ * process leak bug that causes unbounded memory growth:
6
+ * https://github.com/apache/couchdb/issues/5871
7
+ *
8
+ * Watches the _users database changes feed and ensures that every user
9
+ * has a corresponding userdb-{hex(username)} database with appropriate
10
+ * security settings. Replays from since=0 on every startup — this is
11
+ * cheap and removes the need to persist checkpoint state.
12
+ */
13
+ import { getCouchDB } from './couchdb/index.js';
14
+ import logger from './logger.js';
15
+ function hexEncode(str) {
16
+ let returnStr = '';
17
+ for (let i = 0; i < str.length; i++) {
18
+ returnStr += ('000' + str.charCodeAt(i).toString(16)).slice(3);
19
+ }
20
+ return returnStr;
21
+ }
22
+ async function ensureUserDB(username) {
23
+ const dbName = `userdb-${hexEncode(username)}`;
24
+ const server = getCouchDB();
25
+ try {
26
+ await server.db.create(dbName);
27
+ logger.info(`peruser: created ${dbName}`);
28
+ }
29
+ catch (e) {
30
+ if (e &&
31
+ typeof e === 'object' &&
32
+ 'statusCode' in e &&
33
+ e.statusCode === 412) {
34
+ // already exists — expected on replay
35
+ }
36
+ else {
37
+ logger.error(`peruser: error creating ${dbName}:`, e);
38
+ return;
39
+ }
40
+ }
41
+ const db = server.use(dbName);
42
+ try {
43
+ const security = {
44
+ admins: { names: [username], roles: [] },
45
+ members: { names: [username], roles: [] },
46
+ };
47
+ await db.insert(security, '_security');
48
+ }
49
+ catch (e) {
50
+ logger.error(`peruser: error setting security on ${dbName}:`, e);
51
+ }
52
+ }
53
+ export function startPerUserProvisioning() {
54
+ const usersDB = getCouchDB().use('_users');
55
+ usersDB.changesReader
56
+ .start({
57
+ includeDocs: false,
58
+ since: '0',
59
+ })
60
+ .on('change', (change) => {
61
+ if (!change.id.startsWith('org.couchdb.user:'))
62
+ return;
63
+ if (change.deleted)
64
+ return;
65
+ const username = change.id.replace('org.couchdb.user:', '');
66
+ ensureUserDB(username).catch((e) => {
67
+ logger.error(`peruser: unhandled error for ${username}:`, e);
68
+ });
69
+ })
70
+ .on('error', (err) => {
71
+ logger.error('peruser: _users changes feed error:', err);
72
+ });
73
+ logger.info('peruser: watching _users for new accounts');
74
+ }
75
+ //# sourceMappingURL=peruser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peruser.js","sourceRoot":"","sources":["../src/peruser.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.24",
6
+ "version": "0.1.26",
7
7
  "description": "an API",
8
8
  "main": "dist/index.js",
9
9
  "type": "module",
@@ -35,8 +35,8 @@
35
35
  "author": "Colin Kennedy",
36
36
  "license": "GPL-3.0-or-later",
37
37
  "dependencies": {
38
- "@vue-skuilder/common": "^0.1.24",
39
- "@vue-skuilder/db": "^0.1.24",
38
+ "@vue-skuilder/common": "^0.1.26",
39
+ "@vue-skuilder/db": "^0.1.26",
40
40
  "axios": "^1.12.0",
41
41
  "cookie-parser": "^1.4.7",
42
42
  "cors": "^2.8.5",
@@ -77,5 +77,5 @@
77
77
  "typescript-eslint": "^8.48.1",
78
78
  "vitest": "^4.0.15"
79
79
  },
80
- "stableVersion": "0.1.24"
80
+ "stableVersion": "0.1.26"
81
81
  }
@@ -1,8 +1,8 @@
1
1
  import { initializeDataLayer } from '@vue-skuilder/db';
2
- import {
3
- ServerRequestType as RequestEnum,
4
- ServerRequest,
5
- prepareNote55,
2
+ import {
3
+ ServerRequestType as RequestEnum,
4
+ ServerRequest,
5
+ prepareNote55,
6
6
  } from '@vue-skuilder/common';
7
7
  import { CourseLookup } from '@vue-skuilder/db';
8
8
  import cookieParser from 'cookie-parser';
@@ -12,6 +12,7 @@ import express from 'express';
12
12
  import morgan from 'morgan';
13
13
  import Nano from 'nano';
14
14
  import PostProcess from './attachment-preprocessing/index.js';
15
+ import { startPerUserProvisioning } from './peruser.js';
15
16
  import {
16
17
  ClassroomCreationQueue,
17
18
  ClassroomJoinQueue,
@@ -22,8 +23,16 @@ import {
22
23
  initCourseDBDesignDocInsert,
23
24
  } from './client-requests/course-requests.js';
24
25
  import { packCourse } from './client-requests/pack-requests.js';
25
- import { requestIsAuthenticated } from './couchdb/authentication.js';
26
- import { getCouchDB, initializeCouchDB, useOrCreateCourseDB, useOrCreateDB } from './couchdb/index.js';
26
+ import {
27
+ getAuthenticatedUser,
28
+ requestIsAuthenticated,
29
+ } from './couchdb/authentication.js';
30
+ import {
31
+ getCouchDB,
32
+ initializeCouchDB,
33
+ useOrCreateCourseDB,
34
+ useOrCreateDB,
35
+ } from './couchdb/index.js';
27
36
  import { classroomDbDesignDoc } from './design-docs.js';
28
37
  import logger from './logger.js';
29
38
  import logsRouter from './routes/logs.js';
@@ -45,7 +54,9 @@ export type AppConfig = ExpressServerConfig | EnvironmentConfig;
45
54
  /**
46
55
  * Type guard to determine if config is ExpressServerConfig (programmatic usage)
47
56
  */
48
- function isExpressServerConfig(config: AppConfig): config is ExpressServerConfig {
57
+ function isExpressServerConfig(
58
+ config: AppConfig
59
+ ): config is ExpressServerConfig {
49
60
  return 'couchdb' in config && typeof config.couchdb === 'object';
50
61
  }
51
62
 
@@ -70,19 +81,20 @@ function convertToEnvConfig(config: ExpressServerConfig): EnvironmentConfig {
70
81
  */
71
82
  export function createExpressApp(config: AppConfig): express.Application {
72
83
  const app = express();
73
-
84
+
74
85
  // Normalize config to environment format for internal usage
75
- const envConfig = isExpressServerConfig(config)
76
- ? convertToEnvConfig(config)
86
+ const envConfig = isExpressServerConfig(config)
87
+ ? convertToEnvConfig(config)
77
88
  : config;
78
89
 
79
90
  // Initialize CouchDB connection
80
91
  initializeCouchDB(envConfig);
81
92
 
82
93
  // Configure CORS - use config if available, otherwise defaults
83
- const corsOptions = isExpressServerConfig(config) && config.cors
84
- ? config.cors
85
- : { credentials: true, origin: true };
94
+ const corsOptions =
95
+ isExpressServerConfig(config) && config.cors
96
+ ? config.cors
97
+ : { credentials: true, origin: true };
86
98
 
87
99
  // Middleware setup
88
100
  app.use(cookieParser());
@@ -130,7 +142,10 @@ export function createExpressApp(config: AppConfig): express.Application {
130
142
  logger.info(`Delete request made on course ${req.params.courseID}...`);
131
143
  const auth = await requestIsAuthenticated(req);
132
144
  if (auth) {
133
- logger.info(` Authenticated delete request made...`); const dbResp = await getCouchDB().db.destroy( `coursedb-${req.params.courseID}` );
145
+ logger.info(` Authenticated delete request made...`);
146
+ const dbResp = await getCouchDB().db.destroy(
147
+ `coursedb-${req.params.courseID}`
148
+ );
134
149
  if (!dbResp.ok) {
135
150
  res.json({ success: false, error: dbResp });
136
151
  return;
@@ -147,7 +162,9 @@ export function createExpressApp(config: AppConfig): express.Application {
147
162
  }
148
163
  } catch (error) {
149
164
  logger.error('Error deleting course:', error);
150
- res.status(500).json({ success: false, error: 'Failed to delete course' });
165
+ res
166
+ .status(500)
167
+ .json({ success: false, error: 'Failed to delete course' });
151
168
  }
152
169
  })();
153
170
  });
@@ -156,13 +173,15 @@ export function createExpressApp(config: AppConfig): express.Application {
156
173
  req: VueClientRequest,
157
174
  res: express.Response
158
175
  ): Promise<void> {
159
- const auth = await requestIsAuthenticated(req);
160
- if (auth) {
176
+ // Use secure authentication that returns the actual session username
177
+ const authResult = await getAuthenticatedUser(req);
178
+ if (authResult.authenticated) {
179
+ const authenticatedUsername = authResult.username;
161
180
  const body = req.body;
162
181
  logger.info(
163
182
  `Authorized ${
164
183
  body.type ? body.type : '[unspecified request type]'
165
- } request made...`
184
+ } request from ${authenticatedUsername}...`
166
185
  );
167
186
 
168
187
  if (body.type === RequestEnum.CREATE_CLASSROOM) {
@@ -176,8 +195,9 @@ export function createExpressApp(config: AppConfig): express.Application {
176
195
  body.response = await ClassroomJoinQueue.getResult(id);
177
196
  res.json(body.response);
178
197
  } else if (body.type === RequestEnum.LEAVE_CLASSROOM) {
198
+ // SECURITY FIX: Use authenticated username from session, not user-supplied req.body.user
179
199
  const id: number = ClassroomLeaveQueue.addRequest({
180
- username: req.body.user,
200
+ username: authenticatedUsername,
181
201
  ...body.data,
182
202
  });
183
203
  body.response = await ClassroomLeaveQueue.getResult(id);
@@ -196,7 +216,8 @@ export function createExpressApp(config: AppConfig): express.Application {
196
216
  body.data.tags,
197
217
  body.data.uploads
198
218
  );
199
- getCouchDB().use(`coursedb-${body.data.courseID}`)
219
+ getCouchDB()
220
+ .use(`coursedb-${body.data.courseID}`)
200
221
  .insert(payload as Nano.MaybeDocument)
201
222
  .then((r) => {
202
223
  logger.info(`\t\t\tCouchDB insert result: ${JSON.stringify(r)}`);
@@ -212,15 +233,16 @@ export function createExpressApp(config: AppConfig): express.Application {
212
233
  `\tPACK_COURSE request received in production mode, but this is not supported!`
213
234
  );
214
235
  res.status(400);
215
- res.statusMessage = 'Packing courses is not supported in production mode.';
236
+ res.statusMessage =
237
+ 'Packing courses is not supported in production mode.';
216
238
  res.send();
217
239
  return;
218
240
  }
219
-
241
+
220
242
  body.response = await packCourse({
221
243
  courseId: body.courseId,
222
244
  outputPath: body.outputPath,
223
- couchdbUrl: body.couchdbUrl
245
+ couchdbUrl: body.couchdbUrl,
224
246
  });
225
247
  res.json(body.response);
226
248
  }
@@ -243,7 +265,8 @@ export function createExpressApp(config: AppConfig): express.Application {
243
265
  app.get('/', (_req: Request, res: Response) => {
244
266
  let status = `Express service is running.\nVersion: ${envConfig.VERSION}\n`;
245
267
 
246
- getCouchDB().session()
268
+ getCouchDB()
269
+ .session()
247
270
  .then((s) => {
248
271
  if (s.ok) {
249
272
  status += 'Couchdb is running.\n';
@@ -272,8 +295,6 @@ export async function initializeServices(config: AppConfig): Promise<void> {
272
295
  ? convertToEnvConfig(config)
273
296
  : config;
274
297
 
275
-
276
-
277
298
  await initializeDataLayer({
278
299
  type: 'couch',
279
300
  options: {
@@ -301,10 +322,21 @@ export async function initializeServices(config: AppConfig): Promise<void> {
301
322
  // media uploads
302
323
  void PostProcess();
303
324
 
325
+ // start the per-user database provisioning listener
326
+ // (replaces CouchDB's couch_peruser, which has a process leak)
327
+ // skip in studio mode — studio is a local single-user environment
328
+ if (envConfig.NODE_ENV !== 'studio') {
329
+ startPerUserProvisioning();
330
+ }
331
+
304
332
  initCourseDBDesignDocInsert().catch((error) => {
305
- logger.error(`Error in initCourseDBDesignDocInsert background task: ${error}`);
333
+ logger.error(
334
+ `Error in initCourseDBDesignDocInsert background task: ${error}`
335
+ );
306
336
  if (error && typeof error === 'object') {
307
- logger.error(`Full error details in initCourseDBDesignDocInsert: ${JSON.stringify(error)}`);
337
+ logger.error(
338
+ `Full error details in initCourseDBDesignDocInsert: ${JSON.stringify(error)}`
339
+ );
308
340
  }
309
341
  });
310
342
 
@@ -325,4 +357,4 @@ export async function initializeServices(config: AppConfig): Promise<void> {
325
357
  } catch (e) {
326
358
  logger.info(`Error: ${JSON.stringify(e)}`);
327
359
  }
328
- }
360
+ }
@@ -58,8 +58,28 @@ interface LoudnessData {
58
58
  target_offset: string;
59
59
  }
60
60
 
61
+ // Fade durations in seconds — short enough to preserve consonant onsets,
62
+ // long enough to smooth out the noise-floor cut-in/cut-out
63
+ const FADE_IN_DURATION = 0.05; // 50ms
64
+ const FADE_OUT_DURATION = 0.1; // 100ms
65
+
66
+ function parseDuration(output: string): number {
67
+ const match = output.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/);
68
+ if (!match) throw new Error('Could not parse duration from ffmpeg output');
69
+ const hours = parseInt(match[1]);
70
+ const minutes = parseInt(match[2]);
71
+ const seconds = parseInt(match[3]);
72
+ const frac = parseInt(match[4]) / Math.pow(10, match[4].length);
73
+ return hours * 3600 + minutes * 60 + seconds + frac;
74
+ }
75
+
76
+ async function getAudioDuration(filePath: string): Promise<number> {
77
+ const result = await exec(`"${FFMPEG}" -i "${filePath}" -f null -`);
78
+ return parseDuration(result.stderr);
79
+ }
80
+
61
81
  /**
62
- * Normalizes a single wav file to mp3 with loudnorm
82
+ * Normalizes a single wav file to mp3 with loudnorm and fade in/out
63
83
  * Same spec as the attachment preprocessing: I=-16:TP=-1.5:LRA=11
64
84
  */
65
85
  async function normalizeFile(
@@ -73,6 +93,7 @@ async function normalizeFile(
73
93
 
74
94
  const PADDED = path.join(tmpDir, 'padded.wav');
75
95
  const PADDED_NORMALIZED = path.join(tmpDir, 'paddedNormalized.wav');
96
+ const TRIMMED = path.join(tmpDir, 'trimmed.wav');
76
97
  const NORMALIZED = path.join(tmpDir, 'normalized.mp3');
77
98
 
78
99
  try {
@@ -108,10 +129,23 @@ async function normalizeFile(
108
129
  `print_format=summary -ar 48k "${PADDED_NORMALIZED}"`
109
130
  );
110
131
 
111
- // Cut off the padded part and convert to mp3
112
- log(`[${baseName}] Cutting padding and encoding to mp3...`);
132
+ // Cut off the padded silence (keep as wav to avoid double lossy encoding)
133
+ log(`[${baseName}] Cutting padding...`);
134
+ await exec(
135
+ `"${FFMPEG}" -i "${PADDED_NORMALIZED}" -ss 00:00:10.000 "${TRIMMED}"`
136
+ );
137
+
138
+ // Apply fade in/out and encode to mp3
139
+ const duration = await getAudioDuration(TRIMMED);
140
+ const fadeOutStart = Math.max(0, duration - FADE_OUT_DURATION);
141
+ log(
142
+ `[${baseName}] Applying fades (in=${FADE_IN_DURATION}s, out=${FADE_OUT_DURATION}s @ ${fadeOutStart.toFixed(3)}s) and encoding to mp3...`
143
+ );
113
144
  await exec(
114
- `"${FFMPEG}" -i "${PADDED_NORMALIZED}" -ss 00:00:10.000 -acodec libmp3lame -b:a 192k "${NORMALIZED}"`
145
+ `"${FFMPEG}" -i "${TRIMMED}" -af ` +
146
+ `"afade=t=in:st=0:d=${FADE_IN_DURATION}:curve=qsin,` +
147
+ `afade=t=out:st=${fadeOutStart}:d=${FADE_OUT_DURATION}:curve=qsin" ` +
148
+ `-acodec libmp3lame -b:a 192k "${NORMALIZED}"`
115
149
  );
116
150
 
117
151
  // Copy to output location
@@ -28,23 +28,25 @@ interface PackCourseResponse {
28
28
  function extractOriginalCourseId(decoratedId: string): string {
29
29
  // Remove unpacked_ prefix if present
30
30
  let courseId = decoratedId.replace(/^unpacked_/, '');
31
-
31
+
32
32
  // Remove timestamp_random suffix pattern: _YYYYMMDD_abcdef
33
33
  courseId = courseId.replace(/_\d{8}_[a-z0-9]{6}$/, '');
34
-
34
+
35
35
  // If no changes were made, return original (handles non-decorated IDs)
36
36
  return courseId === decoratedId ? decoratedId : courseId;
37
37
  }
38
38
 
39
- export async function packCourse(data: PackCourseData): Promise<PackCourseResponse> {
39
+ export async function packCourse(
40
+ data: PackCourseData
41
+ ): Promise<PackCourseResponse> {
40
42
  logger.info(`Starting PACK_COURSE for ${data.courseId}...`);
41
-
43
+
42
44
  try {
43
45
  const startTime = Date.now();
44
-
46
+
45
47
  // Use CouchDBToStaticPacker directly from db package
46
48
  const { CouchDBToStaticPacker } = await import('@vue-skuilder/db');
47
-
49
+
48
50
  // Create database connection URL - use provided couchdbUrl if available (studio mode)
49
51
  logger.info(`Pack request data: ${JSON.stringify(data, null, 2)}`);
50
52
  let courseDbUrl: string;
@@ -53,21 +55,23 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
53
55
  logger.info(`Using provided CouchDB URL: "${courseDbUrl}"`);
54
56
  } else {
55
57
  // Fallback to ENV configuration for production mode
56
- logger.info(`ENV values - Protocol: "${ENV.COUCHDB_PROTOCOL}", Admin: "${ENV.COUCHDB_ADMIN}", Password: "${ENV.COUCHDB_PASSWORD}", Server: "${ENV.COUCHDB_SERVER}"`);
58
+ logger.info(
59
+ `ENV values - Protocol: "${ENV.COUCHDB_PROTOCOL}", Admin: "${ENV.COUCHDB_ADMIN}", Password: PROBABLY_NOT_THE_PASSORD, Server: "${ENV.COUCHDB_SERVER}"`
60
+ );
57
61
  const dbUrl = `${ENV.COUCHDB_PROTOCOL}://${ENV.COUCHDB_ADMIN}:${ENV.COUCHDB_PASSWORD}@${ENV.COUCHDB_SERVER}`;
58
62
  const dbName = `coursedb-${data.courseId}`;
59
63
  courseDbUrl = `${dbUrl}/${dbName}`;
60
64
  logger.info(`Constructed dbUrl from ENV: "${courseDbUrl}"`);
61
65
  }
62
-
66
+
63
67
  // Determine output path based on environment and provided path
64
68
  let outputPath: string;
65
-
69
+
66
70
  if (data.outputPath) {
67
71
  // If output path is provided, check if it's absolute or relative
68
72
  const pathModule = await import('path');
69
73
  const path = pathModule.default || pathModule;
70
-
74
+
71
75
  if (path.isAbsolute(data.outputPath)) {
72
76
  // Use absolute path as-is
73
77
  outputPath = data.outputPath;
@@ -78,39 +82,45 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
78
82
  }
79
83
  } else {
80
84
  // No output path provided - use default
81
- outputPath = ENV.NODE_ENV === 'studio' ?
82
- '/tmp/skuilder-studio-output' :
83
- process.cwd();
85
+ outputPath =
86
+ ENV.NODE_ENV === 'studio'
87
+ ? '/tmp/skuilder-studio-output'
88
+ : process.cwd();
84
89
  }
85
-
86
- logger.info(`Packing course ${data.courseId} from ${courseDbUrl} to ${outputPath}`);
87
-
90
+
91
+ logger.info(
92
+ `Packing course ${data.courseId} from ${courseDbUrl} to ${outputPath}`
93
+ );
94
+
88
95
  // Clean up existing output directory for replace-in-place functionality
89
96
  const fsExtra = await import('fs-extra');
90
97
  const fs = fsExtra.default || fsExtra;
91
-
98
+
92
99
  try {
93
100
  if (await fs.pathExists(outputPath)) {
94
101
  logger.info(`Removing existing directory: ${outputPath}`);
95
102
  await fs.remove(outputPath);
96
103
  }
97
104
  } catch (cleanupError) {
98
- logger.warn(`Warning: Could not clean up existing directory ${outputPath}:`, cleanupError);
105
+ logger.warn(
106
+ `Warning: Could not clean up existing directory ${outputPath}:`,
107
+ cleanupError
108
+ );
99
109
  // Continue anyway - the write operation might still succeed
100
110
  }
101
-
111
+
102
112
  // Initialize packer and perform pack operation with file writing
103
113
  const packer = new CouchDBToStaticPacker();
104
-
114
+
105
115
  // For Express, we create a simple FileSystemAdapter using dynamic imports
106
116
  const createFsAdapter = async () => {
107
117
  const fsExtra = await import('fs-extra');
108
118
  const pathModule = await import('path');
109
-
119
+
110
120
  // Access the default export for fs-extra
111
121
  const fs = fsExtra.default || fsExtra;
112
122
  const path = pathModule.default || pathModule;
113
-
123
+
114
124
  return {
115
125
  async readFile(filePath: string): Promise<string> {
116
126
  return await fs.readFile(filePath, 'utf8');
@@ -131,13 +141,20 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
131
141
  return {
132
142
  isDirectory: () => stats.isDirectory(),
133
143
  isFile: () => stats.isFile(),
134
- size: stats.size
144
+ size: stats.size,
135
145
  };
136
146
  },
137
- async writeFile(filePath: string, data: string | Buffer): Promise<void> {
147
+ async writeFile(
148
+ filePath: string,
149
+ data: string | Buffer
150
+ ): Promise<void> {
138
151
  await fs.writeFile(filePath, data as string | Uint8Array);
139
152
  },
140
- async writeJson(filePath: string, data: unknown, options?: { spaces?: number }): Promise<void> {
153
+ async writeJson(
154
+ filePath: string,
155
+ data: unknown,
156
+ options?: { spaces?: number }
157
+ ): Promise<void> {
141
158
  await fs.writeJson(filePath, data, options);
142
159
  },
143
160
  async ensureDir(dirPath: string): Promise<void> {
@@ -151,25 +168,32 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
151
168
  },
152
169
  isAbsolute(filePath: string): boolean {
153
170
  return path.isAbsolute(filePath);
154
- }
171
+ },
155
172
  };
156
173
  };
157
-
174
+
158
175
  const fsAdapter = await createFsAdapter();
159
-
176
+
160
177
  // Use regular PouchDB for simple data reading
161
178
  logger.info(`Creating PouchDB instance with URL: ${courseDbUrl}`);
162
179
  // logger.info(`PouchDB constructor available: ${typeof PouchDB}`);
163
180
  // logger.info(`PouchDB adapters: ${JSON.stringify(Object.keys((PouchDB as any).adapters || {}))}`);
164
-
181
+
165
182
  const courseDb = new PouchDB(courseDbUrl);
166
183
  // logger.info(`PouchDB instance created, adapter: ${(courseDb as any).adapter}`);
167
-
184
+
168
185
  // Extract original courseId from decorated database name for manifest generation
169
186
  const originalCourseId = extractOriginalCourseId(data.courseId);
170
- logger.info(`Using originalCourseId "${originalCourseId}" for manifest (extracted from "${data.courseId}")`);
171
-
172
- const packResult = await packer.packCourseToFiles(courseDb, originalCourseId, outputPath, fsAdapter);
187
+ logger.info(
188
+ `Using originalCourseId "${originalCourseId}" for manifest (extracted from "${data.courseId}")`
189
+ );
190
+
191
+ const packResult = await packer.packCourseToFiles(
192
+ courseDb,
193
+ originalCourseId,
194
+ outputPath,
195
+ fsAdapter
196
+ );
173
197
 
174
198
  // Create course-level skuilder.json for the packed course
175
199
  const pathModule = await import('path');
@@ -181,9 +205,12 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
181
205
  try {
182
206
  const manifestContent = await fsAdapter.readFile(manifestPath);
183
207
  const manifest = JSON.parse(manifestContent);
184
- courseTitle = manifest.courseName || manifest.courseConfig?.name || originalCourseId;
208
+ courseTitle =
209
+ manifest.courseName || manifest.courseConfig?.name || originalCourseId;
185
210
  } catch (error) {
186
- logger.warn(`Could not read manifest for course title, using courseId: ${error}`);
211
+ logger.warn(
212
+ `Could not read manifest for course title, using courseId: ${error}`
213
+ );
187
214
  }
188
215
 
189
216
  // Create course-level skuilder.json
@@ -198,7 +225,9 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
198
225
  };
199
226
 
200
227
  const skuilderJsonPath = path.join(outputPath, 'skuilder.json');
201
- await fsAdapter.writeJson(skuilderJsonPath, courseSkuilderJson, { spaces: 2 });
228
+ await fsAdapter.writeJson(skuilderJsonPath, courseSkuilderJson, {
229
+ spaces: 2,
230
+ });
202
231
  logger.info(`Created skuilder.json for course: ${originalCourseId}`);
203
232
 
204
233
  const duration = Date.now() - startTime;
@@ -210,24 +239,27 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
210
239
  attachmentsFound: packResult.attachmentsFound,
211
240
  filesWritten: packResult.filesWritten + 1, // +1 for skuilder.json
212
241
  totalFiles: packResult.filesWritten + 1, // Updated to reflect actual files written including skuilder.json
213
- duration: duration
242
+ duration: duration,
214
243
  };
215
244
 
216
- logger.info(`Pack completed in ${duration}ms. Attachments: ${response.attachmentsFound}, Files written: ${response.filesWritten}`);
245
+ logger.info(
246
+ `Pack completed in ${duration}ms. Attachments: ${response.attachmentsFound}, Files written: ${response.filesWritten}`
247
+ );
217
248
 
218
249
  return response;
219
250
  } catch (error) {
220
251
  logger.error('Pack operation failed:', error);
221
-
252
+
222
253
  const response: PackCourseResponse = {
223
254
  status: Status.error,
224
255
  ok: false,
225
- errorText: error instanceof Error ? error.message : 'Pack operation failed'
256
+ errorText:
257
+ error instanceof Error ? error.message : 'Pack operation failed',
226
258
  };
227
-
259
+
228
260
  return response;
229
261
  }
230
262
  }
231
263
 
232
264
  // Export types for use in app.ts
233
- export type { PackCourseData, PackCourseResponse };
265
+ export type { PackCourseData, PackCourseResponse };