@abelionorg/core 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@abelionorg/core",
3
+ "version": "3.5.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./*": "./src/*.ts"
12
+ },
13
+ "browser": {
14
+ "./src/storage/FileAdapter.ts": "./src/storage/FileAdapter.shim.ts"
15
+ },
16
+ "scripts": {
17
+ "lint": "eslint ."
18
+ },
19
+ "dependencies": {
20
+ "@google/generative-ai": "^0.24.1",
21
+ "@noble/ciphers": "^2.1.1",
22
+ "@noble/hashes": "^2.0.1",
23
+ "idb": "^8.0.3",
24
+ "uuid": "^13.0.0",
25
+ "zustand": "^5.0.11",
26
+ "react": "19.2.4"
27
+ },
28
+ "devDependencies": {
29
+ "eslint": "^9",
30
+ "@types/react": "^19"
31
+ }
32
+ }
package/src/Arsip.ts ADDED
@@ -0,0 +1,513 @@
1
+ import { Gudang } from './Gudang';
2
+ import { Brankas, Hasil } from './Brankas';
3
+ import { Note, EntityId } from './Rumus';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { Integritas } from './Integritas';
6
+ import { Pujangga } from './Pujangga';
7
+
8
+ /**
9
+ * Arsip: Modul utama manajemen brankas dan catatan Lembaran.
10
+ * Menangani siklus hidup data dari enkripsi, penyimpanan, hingga pemulihan.
11
+ */
12
+ export const Arsip = {
13
+ async isVaultInitialized(): Promise<Hasil<boolean>> {
14
+ try {
15
+ const validator = await Gudang.get('meta', 'auth_validator');
16
+ return { data: !!validator, error: null };
17
+ } catch (e) {
18
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
19
+ }
20
+ },
21
+
22
+ /**
23
+ * Menyiapkan brankas baru dengan kata sandi dan kunci pemulihan (mnemonic).
24
+ * @param password Kata sandi utama
25
+ * @param mnemonic 12 kata kunci pemulihan (opsional)
26
+ */
27
+ async setupVault(password: string, mnemonic?: string): Promise<Hasil<void>> {
28
+ if (process.env.DEBUG === 'true') console.log('[ARSIP] Memulai setupVault...');
29
+
30
+ const genResult = await Brankas.generateMasterKey();
31
+ if (genResult.error) return genResult;
32
+ const masterKey = genResult.data;
33
+
34
+ const exportResult = await Brankas.exportRawKey(masterKey);
35
+ if (exportResult.error) return exportResult;
36
+ const masterKeyBuffer = exportResult.data;
37
+
38
+ const salt = crypto.getRandomValues(new Uint8Array(16));
39
+ const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
40
+
41
+ const deriveResult = await Brankas.deriveKey(password, salt);
42
+ if (deriveResult.error) return deriveResult;
43
+ const passwordKey = deriveResult.data;
44
+
45
+ const wrapResult = await Brankas.encryptPacked(
46
+ btoa(String.fromCharCode(...new Uint8Array(masterKeyBuffer))),
47
+ passwordKey
48
+ );
49
+ if (wrapResult.error) return wrapResult;
50
+ const wrappedKey = wrapResult.data;
51
+
52
+ const validator = 'LEMBARAN_SECURED_V3';
53
+ const valEncryptResult = await Brankas.encryptPacked(validator, masterKey);
54
+ if (valEncryptResult.error) return valEncryptResult;
55
+ const encryptedValidator = valEncryptResult.data;
56
+
57
+ await Gudang.set('meta', 'auth_salt', saltHex);
58
+ await Gudang.set('meta', 'auth_wrapped_key', wrappedKey);
59
+ await Gudang.set('meta', 'auth_validator', encryptedValidator);
60
+
61
+ if (mnemonic) {
62
+ const mnemonicSalt = crypto.getRandomValues(new Uint8Array(16));
63
+ const mSaltHex = Array.from(mnemonicSalt).map(b => b.toString(16).padStart(2, '0')).join('');
64
+
65
+ const mDeriveResult = await Brankas.deriveKey(mnemonic, mnemonicSalt);
66
+ if (mDeriveResult.error) return mDeriveResult;
67
+ const recoveryKey = mDeriveResult.data;
68
+
69
+ const mWrapResult = await Brankas.encryptPacked(
70
+ btoa(String.fromCharCode(...new Uint8Array(masterKeyBuffer))),
71
+ recoveryKey
72
+ );
73
+ if (mWrapResult.error) return mWrapResult;
74
+ const recoveryWrappedKey = mWrapResult.data;
75
+
76
+ await Gudang.set('meta', 'recovery_salt', mSaltHex);
77
+ await Gudang.set('meta', 'recovery_wrapped_key', recoveryWrappedKey);
78
+ }
79
+
80
+ Brankas.setActiveKey(masterKey);
81
+ return { data: undefined, error: null };
82
+ },
83
+
84
+ /**
85
+ * Membuka brankas menggunakan kata sandi.
86
+ * Mendukung migrasi otomatis dari V2 ke V3.
87
+ */
88
+ async unlockVault(password: string): Promise<Hasil<boolean>> {
89
+ try {
90
+ // Panic Key Check
91
+ const panicHash = await Gudang.get('meta', 'panic_hash') as string;
92
+ if (panicHash) {
93
+ const currentHash = await Integritas.hitungHash(password);
94
+ if (currentHash === panicHash) {
95
+ await this.destroyAllData();
96
+ return { data: false, error: null };
97
+ }
98
+ }
99
+
100
+ const saltHex = await Gudang.get('meta', 'auth_salt') as string;
101
+ const authValidator = await Gudang.get('meta', 'auth_validator') as string;
102
+ const wrappedKey = await Gudang.get('meta', 'auth_wrapped_key') as string;
103
+
104
+ if (!saltHex || !authValidator) return { data: null, error: new Error('Data otentikasi tidak lengkap') };
105
+
106
+ const salt = new Uint8Array(saltHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
107
+
108
+ const deriveResult = await Brankas.deriveKey(password, salt);
109
+ if (deriveResult.error) return deriveResult as Hasil<boolean>;
110
+ const passwordKey = deriveResult.data;
111
+
112
+ // Coba V3 (Decoupled Master Key)
113
+ if (wrappedKey) {
114
+ const decResult = await Brankas.decryptPacked(wrappedKey, passwordKey);
115
+ if (decResult.error) return decResult as Hasil<boolean>;
116
+
117
+ const masterKeyBuffer = Uint8Array.from(atob(decResult.data), c => c.charCodeAt(0)).buffer;
118
+
119
+ const importResult = await Brankas.importRawKey(masterKeyBuffer);
120
+ if (importResult.error) return importResult as Hasil<boolean>;
121
+ const masterKey = importResult.data;
122
+
123
+ const valResult = await Brankas.decryptPacked(authValidator, masterKey);
124
+ if (valResult.error) return valResult as Hasil<boolean>;
125
+
126
+ if (valResult.data === 'LEMBARAN_SECURED_V3') {
127
+ Brankas.setActiveKey(masterKey);
128
+ return { data: true, error: null };
129
+ }
130
+ } else {
131
+ // Migrasi dari V2 (Master Key = Password Key)
132
+ const [ivHex, base64Data] = authValidator.split('|');
133
+ const iv = new Uint8Array(ivHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
134
+ const binaryString = atob(base64Data);
135
+ const bytes = new Uint8Array(binaryString.length);
136
+ for (let i = 0; i < binaryString.length; i++) {
137
+ bytes[i] = binaryString.charCodeAt(i);
138
+ }
139
+
140
+ const decResult = await Brankas.decrypt(bytes.buffer, iv, passwordKey);
141
+ if (decResult.error) return decResult as Hasil<boolean>;
142
+
143
+ if (decResult.data === 'LEMBARAN_SECURED_V2') {
144
+ Brankas.setActiveKey(passwordKey);
145
+ // Lakukan migrasi ke V3 agar support reset password & recovery yang lebih baik
146
+ const resetRes = await this.resetPassword(password);
147
+ if (resetRes.error) console.warn('[ARSIP] Gagal migrasi otomatis ke V3:', resetRes.error.message);
148
+ return { data: true, error: null };
149
+ }
150
+ }
151
+
152
+ return { data: false, error: null };
153
+ } catch (e) {
154
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
155
+ }
156
+ },
157
+
158
+ /**
159
+ * Memulihkan akses brankas menggunakan Kunci Kertas (mnemonic).
160
+ */
161
+ async recoverVault(mnemonic: string): Promise<Hasil<boolean>> {
162
+ try {
163
+ const mSaltHex = await Gudang.get('meta', 'recovery_salt') as string;
164
+ const wrappedKey = await Gudang.get('meta', 'recovery_wrapped_key') as string;
165
+
166
+ if (!mSaltHex || !wrappedKey) return { data: null, error: new Error('Data pemulihan tidak ditemukan') };
167
+
168
+ const mSalt = new Uint8Array(mSaltHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
169
+
170
+ const deriveResult = await Brankas.deriveKey(mnemonic, mSalt);
171
+ if (deriveResult.error) return deriveResult as Hasil<boolean>;
172
+ const recoveryKey = deriveResult.data;
173
+
174
+ const decResult = await Brankas.decryptPacked(wrappedKey, recoveryKey);
175
+ if (decResult.error) return decResult as Hasil<boolean>;
176
+
177
+ const keyBuffer = Uint8Array.from(atob(decResult.data), c => c.charCodeAt(0)).buffer;
178
+
179
+ const importResult = await Brankas.importRawKey(keyBuffer);
180
+ if (importResult.error) return importResult as Hasil<boolean>;
181
+
182
+ Brankas.setActiveKey(importResult.data);
183
+ return { data: true, error: null };
184
+ } catch (e) {
185
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
186
+ }
187
+ },
188
+
189
+ /**
190
+ * Menetapkan kata sandi baru untuk brankas yang sedang terbuka.
191
+ */
192
+ async resetPassword(newPassword: string): Promise<Hasil<void>> {
193
+ const masterKey = Brankas.getActiveKey();
194
+ if (!masterKey) return { data: null, error: new Error('Brankas Terkunci') };
195
+
196
+ const exportResult = await Brankas.exportRawKey(masterKey);
197
+ if (exportResult.error) return exportResult;
198
+ const masterKeyBuffer = exportResult.data;
199
+
200
+ const salt = crypto.getRandomValues(new Uint8Array(16));
201
+ const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
202
+
203
+ const deriveResult = await Brankas.deriveKey(newPassword, salt);
204
+ if (deriveResult.error) return deriveResult;
205
+ const passwordKey = deriveResult.data;
206
+
207
+ const wrapResult = await Brankas.encryptPacked(
208
+ btoa(String.fromCharCode(...new Uint8Array(masterKeyBuffer))),
209
+ passwordKey
210
+ );
211
+ if (wrapResult.error) return wrapResult;
212
+ const wrappedKey = wrapResult.data;
213
+
214
+ await Gudang.set('meta', 'auth_salt', saltHex);
215
+ await Gudang.set('meta', 'auth_wrapped_key', wrappedKey);
216
+
217
+ const validator = 'LEMBARAN_SECURED_V3';
218
+ const valEncryptResult = await Brankas.encryptPacked(validator, masterKey);
219
+ if (valEncryptResult.error) return valEncryptResult;
220
+
221
+ await Gudang.set('meta', 'auth_validator', valEncryptResult.data);
222
+ return { data: undefined, error: null };
223
+ },
224
+
225
+ /**
226
+ * Menghapus seluruh data aplikasi secara permanen.
227
+ */
228
+ async destroyAllData(): Promise<void> {
229
+ await Promise.all([
230
+ Gudang.clear('notes'),
231
+ Gudang.clear('folders'),
232
+ Gudang.clear('meta')
233
+ ]);
234
+
235
+ if (typeof window !== 'undefined') {
236
+ const keys = Object.keys(window.localStorage);
237
+ keys.forEach(key => {
238
+ if (key.startsWith('lembaran:')) {
239
+ window.localStorage.removeItem(key);
240
+ }
241
+ });
242
+ window.location.href = '/';
243
+ }
244
+ },
245
+
246
+ /**
247
+ * Menyimpan catatan baru atau memperbarui catatan lama.
248
+ */
249
+ async saveNote(note: Omit<Note, 'updatedAt'>): Promise<Hasil<Note>> {
250
+ if (Brankas.isLocked()) {
251
+ return { data: null, error: new Error('Brankas terkunci. Tidak dapat menyimpan data.') };
252
+ }
253
+
254
+ try {
255
+ let title = note.title;
256
+ if (!title || title === 'Tanpa Judul') {
257
+ title = await Pujangga.sarankanJudul(note.content);
258
+ }
259
+
260
+ const suggestedTags = await Pujangga.sarankanTag(note.content);
261
+ const tags = Array.from(new Set([...(note.tags || []), ...suggestedTags]));
262
+
263
+ const noteWithId = {
264
+ ...note,
265
+ title,
266
+ tags,
267
+ id: note.id || uuidv4(),
268
+ createdAt: note.createdAt || new Date().toISOString(),
269
+ } as Note;
270
+
271
+ const checkHash = await Integritas.hitungHash(noteWithId);
272
+
273
+ const existing = await Gudang.get("notes", noteWithId.id);
274
+ if (existing && existing._hash === checkHash) {
275
+ return { data: existing, error: null };
276
+ }
277
+
278
+ const preview = await Pujangga.ringkasCerdas(noteWithId.content);
279
+
280
+ const [resTitle, resContent, resPreview] = await Promise.all([
281
+ Brankas.encryptPacked(noteWithId.title),
282
+ Brankas.encryptPacked(noteWithId.content),
283
+ Brankas.encryptPacked(preview)
284
+ ]);
285
+
286
+ if (resTitle.error) return resTitle as Hasil<Note>;
287
+ if (resContent.error) return resContent as Hasil<Note>;
288
+ if (resPreview.error) return resPreview as Hasil<Note>;
289
+
290
+ let secureKredensial: string | undefined = undefined;
291
+ if (note.kredensial) {
292
+ const credsStr = typeof note.kredensial === 'string' ? note.kredensial : JSON.stringify(note.kredensial);
293
+ const resCreds = await Brankas.encryptPacked(credsStr);
294
+ if (resCreds.error) return resCreds as Hasil<Note>;
295
+ secureKredensial = resCreds.data;
296
+ }
297
+
298
+ const finalNote: Note = {
299
+ ...noteWithId,
300
+ title: resTitle.data,
301
+ content: resContent.data,
302
+ preview: resPreview.data,
303
+ kredensial: secureKredensial as any,
304
+ updatedAt: new Date().toISOString(),
305
+ _hash: checkHash,
306
+ };
307
+
308
+ await Gudang.set('notes', finalNote.id, finalNote);
309
+ return { data: finalNote, error: null };
310
+ } catch (e) {
311
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
312
+ }
313
+ },
314
+
315
+ /**
316
+ * Mengambil seluruh catatan yang sudah didekripsi minimal.
317
+ */
318
+ async getAllNotes(): Promise<Hasil<Note[]>> {
319
+ if (Brankas.isLocked()) return { data: null, error: new Error('Brankas terkunci') };
320
+
321
+ try {
322
+ const rawNotes = await Gudang.getAll('notes') as Note[];
323
+ rawNotes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
324
+
325
+ const decrypted = await Promise.all(rawNotes.map(async n => {
326
+ const resTitle = await Brankas.decryptPacked(n.title);
327
+ const resPreview = await Brankas.decryptPacked(n.preview || '');
328
+
329
+ return {
330
+ ...n,
331
+ title: resTitle.error ? '⚠️ [DATA RUSAK]' : resTitle.data,
332
+ preview: resPreview.error ? '⚠️ [DATA RUSAK]' : resPreview.data,
333
+ content: '🔒 Terkunci',
334
+ kredensial: undefined
335
+ };
336
+ }));
337
+
338
+ return { data: decrypted, error: null };
339
+ } catch (e) {
340
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
341
+ }
342
+ },
343
+
344
+ /**
345
+ * Mendekripsi catatan secara penuh.
346
+ */
347
+ async decryptNote(note: Note): Promise<Hasil<Note>> {
348
+ try {
349
+ const resTitle = await Brankas.decryptPacked(note.title);
350
+ if (resTitle.error) return resTitle as Hasil<Note>;
351
+
352
+ const resContent = await Brankas.decryptPacked(note.content);
353
+ if (resContent.error) return resContent as Hasil<Note>;
354
+
355
+ let decodedCreds = note.kredensial;
356
+ if (typeof note.kredensial === 'string') {
357
+ const resCreds = await Brankas.decryptPacked(note.kredensial);
358
+ if (!resCreds.error) {
359
+ try {
360
+ decodedCreds = JSON.parse(resCreds.data);
361
+ } catch {
362
+ decodedCreds = resCreds.data;
363
+ }
364
+ }
365
+ }
366
+
367
+ const decryptedNote = {
368
+ ...note,
369
+ title: resTitle.data,
370
+ content: resContent.data,
371
+ kredensial: decodedCreds
372
+ };
373
+
374
+ if (note._hash) {
375
+ const actualHash = await Integritas.hitungHash(decryptedNote);
376
+ if (actualHash !== note._hash) {
377
+ decryptedNote.content = `⚠️ PERINGATAN: Segel digital rusak!\n\n` + decryptedNote.content;
378
+ }
379
+ }
380
+
381
+ return { data: decryptedNote, error: null };
382
+ } catch (e) {
383
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
384
+ }
385
+ },
386
+
387
+ /**
388
+ * Menghapus catatan berdasarkan ID.
389
+ */
390
+ async deleteNote(id: EntityId) {
391
+ await Gudang.delete('notes', id);
392
+ },
393
+
394
+ /**
395
+ * Mengambil catatan spesifik berdasarkan ID dan mendekripsinya.
396
+ */
397
+ async getNoteById(id: EntityId): Promise<Hasil<Note | undefined>> {
398
+ if (Brankas.isLocked()) return { data: null, error: new Error('Brankas terkunci') };
399
+ try {
400
+ const note = await Gudang.get('notes', id) as Note;
401
+ if (!note) return { data: undefined, error: null };
402
+ return this.decryptNote(note);
403
+ } catch (e) {
404
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
405
+ }
406
+ },
407
+
408
+ /**
409
+ * Mengambil statistik jumlah catatan dan folder.
410
+ */
411
+ async getStats() {
412
+ try {
413
+ const notesCount = await Gudang.count('notes');
414
+ const foldersCount = await Gudang.count('folders');
415
+ return { notes: notesCount, folders: foldersCount };
416
+ } catch {
417
+ return { notes: 0, folders: 0 };
418
+ }
419
+ },
420
+
421
+ /**
422
+ * Menetapkan Panic Key: kata sandi yang jika dimasukkan saat login
423
+ * akan menghapus semua data brankas secara permanen.
424
+ * @param panicPassword Kata sandi yang akan bertindak sebagai tombol panik
425
+ */
426
+ async setPanicKey(panicPassword: string): Promise<void> {
427
+ const hash = await Integritas.hitungHash(panicPassword);
428
+ await Gudang.set('meta', 'panic_hash', hash);
429
+ },
430
+
431
+ /**
432
+ * Membuat cadangan (backup) portabel.
433
+ * Mendekripsi semua data di memori, membungkusnya dalam JSON plaintext,
434
+ * lalu mengenkripsinya dengan struktur portabel dan password backup.
435
+ * Ini memungkinkan file dibuka di mesin lain.
436
+ */
437
+ async cadangkan(passwordBackup: string): Promise<Hasil<Uint8Array>> {
438
+ if (Brankas.isLocked()) return { data: null, error: new Error('Brankas terkunci') };
439
+
440
+ try {
441
+ const rawNotes = await Gudang.getAll('notes') as Note[];
442
+
443
+ const plainNotes: Note[] = [];
444
+ for (const n of rawNotes) {
445
+ const res = await this.decryptNote(n);
446
+ if (res.error) {
447
+ console.error(`[ARSIP] Gagal dekripsi catatan ${n.id} untuk cadangan.`);
448
+ continue;
449
+ }
450
+ plainNotes.push(res.data);
451
+ }
452
+
453
+ const payload = JSON.stringify({
454
+ version: '3.5.0',
455
+ exportedAt: new Date().toISOString(),
456
+ notes: plainNotes,
457
+ });
458
+
459
+ return await Brankas.encryptPortable(payload, passwordBackup);
460
+ } catch (e) {
461
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
462
+ }
463
+ },
464
+
465
+ /**
466
+ * Memulihkan brankas dari file cadangan portabel.
467
+ * @param buffer Data biner dari file .lembaran
468
+ * @param passwordBackup Password yang digunakan untuk mengenkripsi cadangan
469
+ */
470
+ async pulihkan(buffer: Uint8Array, passwordBackup: string): Promise<Hasil<{ restored: number, skipped: number }>> {
471
+ if (Brankas.isLocked()) return { data: null, error: new Error('Brankas terkunci: Buka brankas sebelum memulihkan data.') };
472
+
473
+ try {
474
+ const resDec = await Brankas.decryptPortable(buffer, passwordBackup);
475
+ if (resDec.error) return { data: null, error: new Error('Gagal membuka file cadangan. Password salah atau file rusak.', { cause: resDec.error }) };
476
+
477
+ const backup = JSON.parse(resDec.data);
478
+ if (!backup.notes || !Array.isArray(backup.notes)) {
479
+ return { data: null, error: new Error('Format cadangan tidak valid: Data notes tidak ditemukan.') };
480
+ }
481
+
482
+ const notes = backup.notes as Note[];
483
+ let restored = 0;
484
+ let skipped = 0;
485
+
486
+ for (const note of notes) {
487
+ // Cek apakah note sudah ada dan lebih baru? (Simple collision detection)
488
+ const existing = await Gudang.get('notes', note.id);
489
+ if (existing) {
490
+ const existingDate = new Date(existing.updatedAt).getTime();
491
+ const newDate = new Date(note.updatedAt).getTime();
492
+ if (existingDate >= newDate) {
493
+ skipped++;
494
+ continue;
495
+ }
496
+ }
497
+
498
+ // Enkripsi ulang menggunakan saveNote (otomatis pakai Master Key aktif)
499
+ const resSave = await this.saveNote(note);
500
+ if (resSave.error) {
501
+ console.error(`[ARSIP] Gagal memulihkan note ${note.id}:`, resSave.error.message);
502
+ skipped++;
503
+ } else {
504
+ restored++;
505
+ }
506
+ }
507
+
508
+ return { data: { restored, skipped }, error: null };
509
+ } catch (e) {
510
+ return { data: null, error: e instanceof Error ? e : new Error(String(e)) };
511
+ }
512
+ }
513
+ };
@@ -0,0 +1,55 @@
1
+ import { Laras } from './Laras';
2
+
3
+ /**
4
+ * AuditLog (Laporan Privasi)
5
+ * Mencatat semua aktivitas Sentinel dan pemrosesan AI untuk transparansi pengguna.
6
+ * Decoupled from Node.js top-level imports to support browser builds.
7
+ */
8
+ export class AuditLog {
9
+ private static readonly LOG_FILE = 'audit-privasi.log';
10
+
11
+ static async catat(aksi: string, data: unknown) {
12
+ if (typeof window !== 'undefined') return;
13
+
14
+ try {
15
+ // Dynamic imports for Node.js ESM environments
16
+ const [fs, path] = await Promise.all([
17
+ import('fs/promises'),
18
+ import('path')
19
+ ]);
20
+
21
+ const sakuDir = (await Laras.temukanJalur('saku')).replace('saku.json', '');
22
+ const logPath = path.join(sakuDir, this.LOG_FILE);
23
+
24
+ const entri = {
25
+ waktu: new Date().toISOString(),
26
+ aksi,
27
+ sumber: 'Sentinel Sovereign',
28
+ data_terproses: data,
29
+ status_privasi: 'TERARING (SCRUBBED)'
30
+ };
31
+
32
+ await fs.appendFile(logPath, JSON.stringify(entri) + '\n');
33
+ } catch (err) {
34
+ console.error('Gagal mencatat log audit:', err);
35
+ }
36
+ }
37
+
38
+ static async bacaLog(): Promise<string> {
39
+ if (typeof window !== 'undefined') return 'Log audit hanya tersedia di aplikasi Desktop.';
40
+
41
+ try {
42
+ const [fs, path] = await Promise.all([
43
+ import('fs/promises'),
44
+ import('path')
45
+ ]);
46
+
47
+ const sakuDir = (await Laras.temukanJalur('saku')).replace('saku.json', '');
48
+ const logPath = path.join(sakuDir, this.LOG_FILE);
49
+
50
+ return await fs.readFile(logPath, 'utf-8');
51
+ } catch {
52
+ return 'Belum ada catatan aktivitas privasi.';
53
+ }
54
+ }
55
+ }