@doubling/compound-sync 1.14.1 → 1.15.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/files.js CHANGED
@@ -28,6 +28,7 @@ import { doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where, } fro
28
28
  import { ref as storageRef, uploadBytes, deleteObject, getBytes, } from 'firebase/storage';
29
29
  import { storagePathFor, extFromPath, contentByteSize, computeContentHash, folderDocId, fileDocId, toBytes, } from './storage-helpers.js';
30
30
  import { planFolderChain, splitPath, } from './folder-chain.js';
31
+ import { logOperation } from './operation-log.js';
31
32
  // Re-export pure helpers so callers (and existing tests) can import
32
33
  // either from here or directly from storage-helpers.js.
33
34
  export { storagePathFor, extFromPath, contentByteSize, computeContentHash, isTextMimeType, mimeTypeFromExt, } from './storage-helpers.js';
@@ -173,11 +174,49 @@ export async function pushFolderToCloud({ filesRef, scope, teamId, userId, docPa
173
174
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
174
175
  return { action: 'skipped', reason: 'shared-scope' };
175
176
  }
177
+ // Validate userId BEFORE any writes so an audit-only failure
178
+ // never leaves committed folder docs behind. Firestore rules
179
+ // require actor.id == request.auth.uid on operationLog creates;
180
+ // an empty actor id would land as permission-denied AFTER the
181
+ // folder chain was already created.
182
+ if (!userId) {
183
+ throw new Error('pushFolderToCloud: userId is required for audit logging');
184
+ }
176
185
  const folderId = await ensureFolderChainInCloud({
177
186
  filesRef, scope, teamId, userId, docPath, now,
178
187
  });
188
+ // Log FolderCreate on any 'ensured' outcome. This is an over-log:
189
+ // ensureFolderChainInCloud is idempotent, so a call that only
190
+ // reuses existing folder docs still emits an entry. Introducing a
191
+ // "was-actually-created" signal requires plumbing through
192
+ // planFolderChain results; deferred to a follow-up because the
193
+ // extra rows are small and BQ dedup on op + subject is trivial.
194
+ if (folderId) {
195
+ // filesRef points at orgs/{orgId}/files; the parent doc is orgs/{orgId}.
196
+ const orgId = orgIdFromFilesRef(filesRef);
197
+ await logOperation({
198
+ db: filesRef.firestore,
199
+ orgId,
200
+ actor: { type: 'user', id: userId },
201
+ subject: { type: 'folder', id: folderId },
202
+ opType: 'FolderCreate',
203
+ path: docPath,
204
+ outcome: 'success',
205
+ });
206
+ }
179
207
  return { action: 'ensured', folderId };
180
208
  }
209
+ // Derive orgId from a files collection ref. filesRef always points
210
+ // at orgs/{orgId}/files; its parent doc is orgs/{orgId}. Extracted
211
+ // so pushFolderToCloud and deleteFolderFromCloud share a single
212
+ // well-named accessor rather than an inline IIFE.
213
+ function orgIdFromFilesRef(filesRef) {
214
+ const parent = filesRef.parent;
215
+ if (!parent) {
216
+ throw new Error('filesRef has no parent (expected orgs/{orgId}/files)');
217
+ }
218
+ return parent.id;
219
+ }
181
220
  /**
182
221
  * Delete the folder doc at `docPath` AND every descendant (files and
183
222
  * sub-folders) in the same scope. Cascade is authoritative: it does
@@ -205,6 +244,15 @@ export async function deleteFolderFromCloud({ filesRef, storage, orgId, scope, t
205
244
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
206
245
  return { action: 'skipped', reason: 'shared-scope' };
207
246
  }
247
+ // Validate userId BEFORE any deletes so an audit-only failure
248
+ // never leaves the cascade committed with no audit trail behind
249
+ // it. Firestore rules require actor.id == request.auth.uid on
250
+ // operationLog creates; without the up-front check the descendants
251
+ // could be deleted and only then would the log emit hit
252
+ // permission-denied.
253
+ if (!userId) {
254
+ throw new Error('deleteFolderFromCloud: userId is required for audit logging');
255
+ }
208
256
  // Pull every doc in this scope; cheap because vaults are small
209
257
  // (hundreds of docs at most). Filtering in memory avoids the
210
258
  // Firestore composite-index requirement for a path-prefix range
@@ -277,9 +325,44 @@ export async function deleteFolderFromCloud({ filesRef, storage, orgId, scope, t
277
325
  if (folderDocSnap) {
278
326
  await deleteDoc(folderDocSnap.ref);
279
327
  }
328
+ const deletedFolderId = folderDocSnap ? folderDocSnap.id : null;
329
+ // Emit a single FolderDelete for the top-level folder OR for the
330
+ // orphan-cleanup case where descendants existed without a
331
+ // top-level folder doc (folderDocSnap null but descendants.length
332
+ // > 0). Both are real mutations and must appear in the audit
333
+ // trail. When folderDocSnap is null we compute the deterministic
334
+ // folderDocId as the subject so downstream analytics can still
335
+ // join on subject.id.
336
+ //
337
+ // Cascaded child deletes are NOT logged individually in this
338
+ // pass; the childCount conveys the cascade blast radius. Per-
339
+ // child auditing (one FileDelete entry per cascaded file) is
340
+ // tracked in a follow-up ticket alongside FolderMove.
341
+ const subjectFolderId = deletedFolderId
342
+ ?? folderDocId({
343
+ scope,
344
+ teamId: scope === 'team' ? (teamId ?? null) : null,
345
+ ownerId: scope === 'private' ? userId : null,
346
+ path: docPath,
347
+ });
348
+ // userId already validated at the top of the function so no
349
+ // inner guard needed. orgId is derived from filesRef.parent
350
+ // instead of the input param so callers that pass a partial
351
+ // input (older tests without orgId, e.g. delete-empty-folder
352
+ // cases) still produce valid audit writes; filesRef.parent.id
353
+ // IS orgId by definition.
354
+ await logOperation({
355
+ db: filesRef.firestore,
356
+ orgId: orgIdFromFilesRef(filesRef),
357
+ actor: { type: 'user', id: userId },
358
+ subject: { type: 'folder', id: subjectFolderId },
359
+ opType: 'FolderDelete',
360
+ path: docPath,
361
+ outcome: 'success',
362
+ });
280
363
  return {
281
364
  action: 'deleted',
282
- folderId: folderDocSnap ? folderDocSnap.id : null,
365
+ folderId: deletedFolderId,
283
366
  childCount: descendants.length,
284
367
  };
285
368
  }
@@ -385,6 +468,29 @@ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope,
385
468
  catch (err) {
386
469
  storageError = err;
387
470
  }
471
+ // Emit-after-mutation (fail-loud): a broken audit emit fails the
472
+ // surrounding pushFileToCloud call. See DOU-271 §5.1.
473
+ //
474
+ // When uploadBlob fails, the Firestore doc is written but the
475
+ // blob is missing; sync/push-outcome.ts treats this as a partial
476
+ // failure ("synced with warnings"), so the audit entry mirrors
477
+ // that: outcome=io_error + captured error object.
478
+ await logOperation({
479
+ db: filesRef.firestore,
480
+ orgId,
481
+ actor: { type: 'user', id: userId },
482
+ subject: { type: 'file', id: fileId },
483
+ opType: 'FileWrite',
484
+ path: docPath,
485
+ preContentHash: null,
486
+ postContentHash: contentHash,
487
+ sizeBytes: size,
488
+ outcome: storageError == null ? 'success' : 'io_error',
489
+ error: storageError == null ? null : {
490
+ code: storageError?.code ?? 'storage-upload-failed',
491
+ message: String(storageError),
492
+ },
493
+ });
388
494
  return { action: 'created', fileId, fileData, storageError };
389
495
  }
390
496
  const docSnap = snapshot.docs[0];
@@ -413,6 +519,7 @@ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope,
413
519
  }
414
520
  const existingMime = typeof existing['mimeType'] === 'string' ? existing['mimeType'] : undefined;
415
521
  const resolvedMime = mimeType || existingMime || 'text/markdown';
522
+ const existingContentHash = typeof existing['contentHash'] === 'string' ? existing['contentHash'] : null;
416
523
  await updateDoc(docSnap.ref, {
417
524
  type: 'file',
418
525
  name: fileName,
@@ -430,6 +537,26 @@ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope,
430
537
  catch (err) {
431
538
  storageError = err;
432
539
  }
540
+ // See the 'created' branch above for the storage-error mapping
541
+ // rationale: partial failures land as outcome=io_error so the
542
+ // audit trail reflects the sync/push-outcome.ts "synced with
543
+ // warnings" state instead of a false 'success'.
544
+ await logOperation({
545
+ db: filesRef.firestore,
546
+ orgId,
547
+ actor: { type: 'user', id: userId },
548
+ subject: { type: 'file', id: docSnap.id },
549
+ opType: 'FileWrite',
550
+ path: docPath,
551
+ preContentHash: existingContentHash,
552
+ postContentHash: contentHash,
553
+ sizeBytes: size,
554
+ outcome: storageError == null ? 'success' : 'io_error',
555
+ error: storageError == null ? null : {
556
+ code: storageError?.code ?? 'storage-upload-failed',
557
+ message: String(storageError),
558
+ },
559
+ });
433
560
  return { action: 'updated', fileId: docSnap.id, storageError };
434
561
  }
435
562
  /**
@@ -506,6 +633,15 @@ export async function deleteFileFromCloud({ filesRef, storage, orgId, userId, sc
506
633
  // throws "Null value error" → SDK surfaces permission-denied. The
507
634
  // desired end state (no doc) is already achieved, so treat as
508
635
  // already-deleted instead of propagating.
636
+ // Read the pre-delete content hash from `preRecheck` rather than
637
+ // the initial query snapshot: preRecheck was fetched immediately
638
+ // before the blob delete, so it reflects any concurrent update in
639
+ // the narrow window between the query and here. Also avoids the
640
+ // duplicate `docSnap.data()` calls the previous version made.
641
+ const preRecheckData = preRecheck.data();
642
+ const existingContentHash = typeof preRecheckData?.['contentHash'] === 'string'
643
+ ? preRecheckData['contentHash']
644
+ : null;
509
645
  try {
510
646
  await deleteDoc(docSnap.ref);
511
647
  }
@@ -519,6 +655,17 @@ export async function deleteFileFromCloud({ filesRef, storage, orgId, userId, sc
519
655
  }
520
656
  throw err;
521
657
  }
658
+ await logOperation({
659
+ db: filesRef.firestore,
660
+ orgId,
661
+ actor: { type: 'user', id: userId },
662
+ subject: { type: 'file', id: fileId },
663
+ opType: 'FileDelete',
664
+ path: docPath,
665
+ preContentHash: existingContentHash,
666
+ postContentHash: null,
667
+ outcome: 'success',
668
+ });
522
669
  return { action: 'deleted', fileId };
523
670
  }
524
671
  //# sourceMappingURL=files.js.map
@@ -0,0 +1,52 @@
1
+ import { type Firestore } from 'firebase/firestore';
2
+ export type OpType = 'FileWrite' | 'FileDelete' | 'FolderCreate' | 'FolderDelete' | 'FolderMove';
3
+ export type ActorType = 'user' | 'agent';
4
+ export type SubjectType = 'file' | 'folder';
5
+ export type Outcome = 'success' | 'integrity_error' | 'io_error' | 'permission_denied';
6
+ export interface OperationLogActor {
7
+ type: ActorType;
8
+ id: string;
9
+ }
10
+ export interface OperationLogSubject {
11
+ type: SubjectType;
12
+ id: string;
13
+ }
14
+ export interface OperationLogError {
15
+ code: string;
16
+ message: string;
17
+ }
18
+ export interface OperationLogEntryInput {
19
+ orgId: string;
20
+ actor: OperationLogActor;
21
+ subject: OperationLogSubject;
22
+ opType: OpType;
23
+ path: string;
24
+ outcome: Outcome;
25
+ preContentHash?: string | null;
26
+ postContentHash?: string | null;
27
+ sizeBytes?: number | null;
28
+ error?: OperationLogError | null;
29
+ agentVersion?: string;
30
+ }
31
+ export interface LogOperationInput extends OperationLogEntryInput {
32
+ db: Firestore;
33
+ }
34
+ export declare const DAEMON_AGENT_VERSION = "1.14.1";
35
+ export interface OperationLogDoc {
36
+ id: string;
37
+ timestamp: string;
38
+ actor: OperationLogActor;
39
+ subject: OperationLogSubject;
40
+ op_type: OpType;
41
+ path: string;
42
+ pre_content_hash: string | null;
43
+ post_content_hash: string | null;
44
+ size_bytes: number | null;
45
+ outcome: Outcome;
46
+ error: OperationLogError | null;
47
+ agent_version: string;
48
+ org_id: string;
49
+ }
50
+ export declare function buildOperationLogDoc(input: OperationLogEntryInput, now?: string, id?: string): OperationLogDoc;
51
+ export declare function logOperation(input: LogOperationInput): Promise<void>;
52
+ //# sourceMappingURL=operation-log.d.ts.map
@@ -0,0 +1,44 @@
1
+ // Daemon-side operation-log emitter. Writes an append-only doc at
2
+ // orgs/{orgId}/operationLog/{ulid} per DOU-271 §5.1's Firestore-
3
+ // intermediary transport. The emitOperationLog Cloud Function (see
4
+ // DOU-298) reads via Admin SDK and forwards to Cloud Logging.
5
+ //
6
+ // The helper is intentionally minimal: caller supplies the operation
7
+ // details, the helper fills in id (ULID), timestamp, and
8
+ // agent_version, and setDoc's throw propagates. A broken audit emit
9
+ // fails the surrounding mutation (fail-loud, per DOU-271 rationale
10
+ // in §5.1: audit + file-doc writes share the same hard-failure
11
+ // surface, so silencing audit failures would hide the class of bug
12
+ // the log exists to expose).
13
+ import { doc, setDoc } from 'firebase/firestore';
14
+ import { ulid } from 'ulid';
15
+ // Sync-daemon version stamped onto every emitted entry. Kept in
16
+ // step with sync/package.json version by hand; a follow-up can
17
+ // inject at build time if drift becomes a concern.
18
+ export const DAEMON_AGENT_VERSION = '1.14.1';
19
+ // Pure envelope builder. Split out for unit testability without a
20
+ // Firestore instance; logOperation composes it with setDoc. Takes
21
+ // `OperationLogEntryInput` (no `db` field) so tests don't need to
22
+ // synthesize a Firestore stub for a function that never uses it.
23
+ export function buildOperationLogDoc(input, now = new Date().toISOString(), id = ulid()) {
24
+ return {
25
+ id,
26
+ timestamp: now,
27
+ actor: input.actor,
28
+ subject: input.subject,
29
+ op_type: input.opType,
30
+ path: input.path,
31
+ pre_content_hash: input.preContentHash ?? null,
32
+ post_content_hash: input.postContentHash ?? null,
33
+ size_bytes: input.sizeBytes ?? null,
34
+ outcome: input.outcome,
35
+ error: input.error ?? null,
36
+ agent_version: input.agentVersion ?? DAEMON_AGENT_VERSION,
37
+ org_id: input.orgId,
38
+ };
39
+ }
40
+ export async function logOperation(input) {
41
+ const entry = buildOperationLogDoc(input);
42
+ await setDoc(doc(input.db, 'orgs', input.orgId, 'operationLog', entry.id), entry);
43
+ }
44
+ //# sourceMappingURL=operation-log.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,8 @@
43
43
  "yjs-file-binding.d.ts",
44
44
  "yjs-binding-state.js",
45
45
  "yjs-binding-state.d.ts",
46
+ "operation-log.js",
47
+ "operation-log.d.ts",
46
48
  "README.md"
47
49
  ],
48
50
  "scripts": {
@@ -50,8 +52,8 @@
50
52
  "build": "tsc",
51
53
  "typecheck": "tsc --noEmit",
52
54
  "prepack": "npm run build",
53
- "test:unit": "node --import tsx --test config.test.ts paths.test.ts storage-helpers.test.ts folder-chain.test.ts team-registry.test.ts auth-persistence.test.ts pre-mint-app-check.test.ts setup-auth-with-pre-mint.test.ts sync-state.test.ts manifest.test.ts push-outcome.test.ts yjs-file-binding.test.ts yjs-binding-state.test.ts files-helpers.test.ts",
54
- "test:integration": "node --import tsx --test --test-timeout=180000 files-push-create-private.test.ts files-push-create-team.test.ts files-push-update-private.test.ts files-push-binary.test.ts files-push-staleness.test.ts files-push-storage-fail.test.ts files-push-no-content.test.ts files-push-stamps.test.ts files-push-folder.test.ts files-delete.test.ts files-delete-folder.test.ts files-read-blob.test.ts org-sync-local-edit.test.ts org-sync-file-ops.test.ts org-sync-mtime.test.ts org-sync-rename.test.ts org-sync-yjs-pull.test.ts org-sync-yjs-push.test.ts org-sync-cold-start.test.ts org-sync-startup-ordering.test.ts org-sync-manifest-reconcile.test.ts org-sync-team-membership-filter.test.ts org-sync-special-chars.test.ts org-sync-copy.test.ts two-user-happy-paths.test.ts two-user-yjs-convergence.test.ts files-concurrency-folder-create.test.ts files-concurrency-file-create.test.ts files-concurrency-file-create-sandbox.test.ts files-concurrency-file-delete.test.ts files-concurrency-folder-delete.test.ts",
55
+ "test:unit": "node --import tsx --test config.test.ts paths.test.ts storage-helpers.test.ts folder-chain.test.ts team-registry.test.ts auth-persistence.test.ts pre-mint-app-check.test.ts setup-auth-with-pre-mint.test.ts sync-state.test.ts manifest.test.ts push-outcome.test.ts yjs-file-binding.test.ts yjs-binding-state.test.ts files-helpers.test.ts operation-log.test.ts",
56
+ "test:integration": "node --import tsx --test --test-timeout=180000 files-push-create-private.test.ts files-push-create-team.test.ts files-push-update-private.test.ts files-push-binary.test.ts files-push-staleness.test.ts files-push-storage-fail.test.ts files-push-no-content.test.ts files-push-stamps.test.ts files-push-folder.test.ts files-delete.test.ts files-delete-folder.test.ts files-read-blob.test.ts operation-log-emit.test.ts org-sync-local-edit.test.ts org-sync-file-ops.test.ts org-sync-mtime.test.ts org-sync-rename.test.ts org-sync-yjs-pull.test.ts org-sync-yjs-push.test.ts org-sync-cold-start.test.ts org-sync-startup-ordering.test.ts org-sync-manifest-reconcile.test.ts org-sync-team-membership-filter.test.ts org-sync-special-chars.test.ts org-sync-copy.test.ts two-user-happy-paths.test.ts two-user-yjs-convergence.test.ts files-concurrency-folder-create.test.ts files-concurrency-file-create.test.ts files-concurrency-file-create-sandbox.test.ts files-concurrency-file-delete.test.ts files-concurrency-folder-delete.test.ts",
55
57
  "test:integration:emulator": "USE_EMULATOR=true firebase emulators:exec --only firestore,storage,auth 'npm run test:integration'",
56
58
  "test": "npm run test:unit && npm run test:integration"
57
59
  },
@@ -60,6 +62,7 @@
60
62
  "chokidar": "^5.0.0",
61
63
  "fast-diff": "^1.3.0",
62
64
  "firebase": "^12.15.0",
65
+ "ulid": "^3.0.2",
63
66
  "yjs": "^13.6.31"
64
67
  },
65
68
  "devDependencies": {