@mj-biz-apps/common-activity-sync 5.42.0 → 5.44.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.
Files changed (53) hide show
  1. package/dist/ActivitySyncEngine.d.ts +93 -1
  2. package/dist/ActivitySyncEngine.d.ts.map +1 -1
  3. package/dist/ActivitySyncEngine.js +295 -15
  4. package/dist/ActivitySyncEngine.js.map +1 -1
  5. package/dist/attachments.d.ts +106 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +147 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/content-capture.d.ts +92 -0
  10. package/dist/content-capture.d.ts.map +1 -0
  11. package/dist/content-capture.js +35 -0
  12. package/dist/content-capture.js.map +1 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/participants.d.ts +30 -0
  18. package/dist/participants.d.ts.map +1 -1
  19. package/dist/participants.js +48 -0
  20. package/dist/participants.js.map +1 -1
  21. package/dist/providers/GraphCalendarTransport.d.ts +115 -0
  22. package/dist/providers/GraphCalendarTransport.d.ts.map +1 -0
  23. package/dist/providers/GraphCalendarTransport.js +139 -0
  24. package/dist/providers/GraphCalendarTransport.js.map +1 -0
  25. package/dist/providers/GraphCommunicationTransport.d.ts.map +1 -1
  26. package/dist/providers/GraphCommunicationTransport.js +18 -4
  27. package/dist/providers/GraphCommunicationTransport.js.map +1 -1
  28. package/dist/providers/GraphMessageMapper.d.ts +2 -0
  29. package/dist/providers/GraphMessageMapper.d.ts.map +1 -1
  30. package/dist/providers/GraphMessageMapper.js +3 -0
  31. package/dist/providers/GraphMessageMapper.js.map +1 -1
  32. package/dist/providers/MSGraphActivitySyncProvider.d.ts +39 -4
  33. package/dist/providers/MSGraphActivitySyncProvider.d.ts.map +1 -1
  34. package/dist/providers/MSGraphActivitySyncProvider.js +45 -10
  35. package/dist/providers/MSGraphActivitySyncProvider.js.map +1 -1
  36. package/dist/providers/MSGraphCalendarSyncProvider.d.ts +55 -14
  37. package/dist/providers/MSGraphCalendarSyncProvider.d.ts.map +1 -1
  38. package/dist/providers/MSGraphCalendarSyncProvider.js +112 -38
  39. package/dist/providers/MSGraphCalendarSyncProvider.js.map +1 -1
  40. package/dist/providers/MessageTransport.d.ts +90 -0
  41. package/dist/providers/MessageTransport.d.ts.map +1 -1
  42. package/dist/providers/MessageTransport.js +72 -0
  43. package/dist/providers/MessageTransport.js.map +1 -1
  44. package/dist/providers/RecordedMessageTransport.d.ts.map +1 -1
  45. package/dist/providers/RecordedMessageTransport.js +25 -11
  46. package/dist/providers/RecordedMessageTransport.js.map +1 -1
  47. package/dist/stages.d.ts +11 -0
  48. package/dist/stages.d.ts.map +1 -1
  49. package/dist/stages.js.map +1 -1
  50. package/dist/types.d.ts +14 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +6 -6
@@ -9,8 +9,11 @@ import { type IMetadataProvider, type UserInfo } from '@memberjunction/core';
9
9
  import { BaseActivitySyncProvider } from './BaseActivitySyncProvider.js';
10
10
  import { type ExtensionStamp } from './extensions.js';
11
11
  import { IdentityResolver } from './identity.js';
12
+ import { type ActivityFileSink } from './attachments.js';
12
13
  import { type IQualificationStage, type QualificationPolicy } from './qualification.js';
13
14
  import { type SyncRunOptions } from './run.js';
15
+ import { type ActivityContentCipher } from './content-capture.js';
16
+ import type { ActivitySourceKind } from './types.js';
14
17
  import { ActivityWriter } from './writer.js';
15
18
  export interface SyncEngineResult {
16
19
  Success: boolean;
@@ -47,7 +50,10 @@ interface ConnectionRow {
47
50
  EndAt: Date | string | null;
48
51
  LastSyncAt: Date | string | null;
49
52
  ActivitySyncProviderTypeID: string | null;
53
+ /** Overrides the provider type's default. Null means "use the type's". */
50
54
  SkippedContentPolicy: string | null;
55
+ /** Overrides the provider type's default key. Null means "use the type's". */
56
+ EncryptionKeyID: string | null;
51
57
  Settings: string | null;
52
58
  }
53
59
  interface ProviderTypeRow {
@@ -63,6 +69,14 @@ interface ProviderTypeRow {
63
69
  * runtime and any check against it quietly never fires.
64
70
  */
65
71
  IsActive: boolean;
72
+ /**
73
+ * Audit retention for messages this connector declines to ingest, and the key that protects it.
74
+ *
75
+ * Both are the TYPE-level default; a connection overrides either. Neither had a reader before
76
+ * this change, so "Overridable per connection" described a fallback chain that did not exist.
77
+ */
78
+ DefaultSkippedContentPolicy: string | null;
79
+ DefaultEncryptionKeyID: string | null;
66
80
  }
67
81
  interface RunSurfaceOptions {
68
82
  /** Injected provider (tests). When omitted, resolved from the type row. */
@@ -80,6 +94,12 @@ interface RunSurfaceOptions {
80
94
  /**
81
95
  * LastError must name the failure, not whatever happened to be Issues[0].
82
96
  * Mapping warnings ("Event X had no usable start time") sort ahead of the actual miss.
97
+ *
98
+ * NOT TRUNCATED. This used to end `.slice(0, 4000)`, the same inherited habit removed from the run's
99
+ * issue list: `ActivitySyncConnection.LastError` is NVARCHAR(MAX), and none of the columns this app
100
+ * writes free text to is 4000 wide. It flattens the issues of EVERY failed surface, so it grows with
101
+ * the number of failures — a run that fails broadly truncates its own diagnosis, which is the one
102
+ * occasion the text is worth reading in full.
83
103
  */
84
104
  export declare function healthErrorFromResults(results: readonly SyncEngineResult[]): string | null;
85
105
  /**
@@ -90,11 +110,69 @@ export declare function healthErrorFromResults(results: readonly SyncEngineResul
90
110
  * is clean for that row writes null.
91
111
  */
92
112
  export declare function collapseExtensionStamps(stamps: readonly ExtensionStamp[]): ExtensionStamp[];
113
+ /**
114
+ * The driver class of the SURFACE being run, which is not always the connection's.
115
+ *
116
+ * `RunConnections` drives a second, calendar surface from the same connection and the same type row,
117
+ * passing the calendar plugin as `source`. Handing `typeRow.DriverClass` to both told a host factory
118
+ * "Microsoft365" on the calendar pass too, so a factory serving both surfaces could not tell them
119
+ * apart: it built a MAIL transport for the calendar, fed Graph message payloads to the event mapper,
120
+ * and every one was dropped for having no start time. That reads as an empty calendar, not as a
121
+ * wiring fault — which is why it survived until a calendar fixture ran end to end.
122
+ *
123
+ * Pure and exported so the mapping can be pinned without standing up a fleet run.
124
+ */
125
+ export declare function SurfaceDriverClass(kind: ActivitySourceKind, typeRow: {
126
+ DriverClass?: string | null;
127
+ CalendarDriverClass?: string | null;
128
+ } | null | undefined, fallback: string): string;
93
129
  export declare class ActivitySyncEngine {
94
130
  private readonly resolver;
95
131
  private readonly writer;
96
132
  private readonly stages;
97
- constructor(resolver?: IdentityResolver, writer?: ActivityWriter, stages?: IQualificationStage[]);
133
+ /**
134
+ * Where attachment BYTES go, when a rule asks for them.
135
+ *
136
+ * Optional and injected rather than imported: storing a file needs MJ's FileStorageEngine and
137
+ * a configured FileStorageAccount, and a host that syncs only metadata should not have to
138
+ * have either. Absent, an item whose rule wants attachments is reported rather than quietly
139
+ * filed without them — the distinction this package exists to keep.
140
+ *
141
+ * DEFAULTS TO THE HOST REGISTRY, because the only production construction of this class is
142
+ * `new ActivitySyncEngine()` inside an Action, where nothing can pass one. Without that
143
+ * default a host could implement the interface and still never be called.
144
+ */
145
+ private readonly fileSink;
146
+ /**
147
+ * How captured content is protected, when a policy says to keep any.
148
+ *
149
+ * Same shape and same reason as `fileSink`: this package implements no crypto, and the only
150
+ * production construction of this class passes no arguments, so the default has to come from
151
+ * the host registry or the seam is unreachable. `common-server` fills it at bootstrap.
152
+ */
153
+ private readonly cipher;
154
+ constructor(resolver?: IdentityResolver, writer?: ActivityWriter, stages?: IQualificationStage[],
155
+ /**
156
+ * Where attachment BYTES go, when a rule asks for them.
157
+ *
158
+ * Optional and injected rather than imported: storing a file needs MJ's FileStorageEngine and
159
+ * a configured FileStorageAccount, and a host that syncs only metadata should not have to
160
+ * have either. Absent, an item whose rule wants attachments is reported rather than quietly
161
+ * filed without them — the distinction this package exists to keep.
162
+ *
163
+ * DEFAULTS TO THE HOST REGISTRY, because the only production construction of this class is
164
+ * `new ActivitySyncEngine()` inside an Action, where nothing can pass one. Without that
165
+ * default a host could implement the interface and still never be called.
166
+ */
167
+ fileSink?: ActivityFileSink | null,
168
+ /**
169
+ * How captured content is protected, when a policy says to keep any.
170
+ *
171
+ * Same shape and same reason as `fileSink`: this package implements no crypto, and the only
172
+ * production construction of this class passes no arguments, so the default has to come from
173
+ * the host registry or the seam is unreachable. `common-server` fills it at bootstrap.
174
+ */
175
+ cipher?: ActivityContentCipher | null);
98
176
  Run(connectionID: string, options: SyncRunOptions, provider: IMetadataProvider, contextUser: UserInfo, source?: BaseActivitySyncProvider, surface?: RunSurfaceOptions): Promise<SyncEngineResult>;
99
177
  /**
100
178
  * Every Active-or-Error connection, once per surface. This is what a scheduled
@@ -116,6 +194,20 @@ export declare class ActivitySyncEngine {
116
194
  private loadRunnableConnections;
117
195
  private loadProviderType;
118
196
  private loadBoundSetIds;
197
+ /**
198
+ * The domains this deployment calls INTERNAL, merged across every rule set bound to the
199
+ * connection.
200
+ *
201
+ * WHY THIS EXISTS. `ActivitySyncRuleSet.InternalDomains` describes itself as "Required for any
202
+ * rule using ParticipantScope", `participants.ts` names it as where the list lives, and the
203
+ * engine passed a hard-coded `[]` — so nothing ever read the column. Same shape as the
204
+ * `CredentialsRef` gap: a column that documents its own purpose, with no reader.
205
+ *
206
+ * MALFORMED IS NOT EMPTY. A list that fails to parse fails the run rather than degrading to
207
+ * `[]`, because `[]` silently inverts every participant rule (see the caller). Parsing itself
208
+ * lives in {@link ParseInternalDomains} so it is testable without standing up a RunView.
209
+ */
210
+ private loadInternalDomains;
119
211
  private loadExclusions;
120
212
  private loadRules;
121
213
  private stampSurfaceWatermark;
@@ -1 +1 @@
1
- {"version":3,"file":"ActivitySyncEngine.d.ts","sourceRoot":"","sources":["../src/ActivitySyncEngine.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ActivitySyncEngine.d.ts","sourceRoot":"","sources":["../src/ActivitySyncEngine.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAGH,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EAChB,MAAM,sBAAsB,CAAC;AAU9B,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAEzE,OAAO,EAIH,KAAK,cAAc,EACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEjD,OAAO,EAA6C,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAUpG,OAAO,EAGH,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAOH,KAAK,cAAc,EACtB,MAAM,UAAU,CAAC;AAClB,OAAO,EAGH,KAAK,qBAAqB,EAC7B,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,kBAAkB,EAAkB,MAAM,YAAY,CAAC;AASrE,OAAO,EAAE,cAAc,EAAyB,MAAM,aAAa,CAAC;AAEpE,MAAM,WAAW,gBAAgB;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,EAAE,IAAI,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,KAAK,CAAC;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,gBAAgB,CAAA;KAAE,CAAC,CAAC;IACpF,MAAM,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,sHAAsH;AACtH,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,UAAU,aAAa;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,+FAA+F;IAC/F,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,OAAO,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IAC9B,KAAK,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IACjC,0BAA0B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,0EAA0E;IAC1E,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,8EAA8E;IAC9E,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,UAAU,eAAe;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,0BAA0B,EAAE,mBAAmB,CAAC;IAChD,mFAAmF;IACnF,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;CACzC;AAED,UAAU,iBAAiB;IACvB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,wBAAwB,CAAC;IAClC;;;OAGG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,aAAa,CAAC;IAC3B,6DAA6D;IAC7D,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,MAAM,GAAG,IAAI,CAK1F;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,GAAG,cAAc,EAAE,CAW3F;AAkBD;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAC9B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,EAChG,QAAQ,EAAE,MAAM,GACjB,MAAM,CAKR;AAED,qBAAa,kBAAkB;IAEvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAvBN,QAAQ,GAAE,gBAAyC,EACnD,MAAM,GAAE,cAAqC,EAC7C,MAAM,GAAE,mBAAmB,EAAiC;IAC7E;;;;;;;;;;;OAWG;IACc,QAAQ,GAAE,gBAAgB,GAAG,IAA6B;IAC3E;;;;;;OAMG;IACc,MAAM,GAAE,qBAAqB,GAAG,IAAkC;IAG1E,GAAG,CACZ,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,cAAc,EACvB,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,QAAQ,EACrB,MAAM,CAAC,EAAE,wBAAwB,EACjC,OAAO,CAAC,EAAE,iBAAiB,GAC5B,OAAO,CAAC,gBAAgB,CAAC;IA+b5B;;;;;;;;OAQG;IACU,cAAc,CACvB,OAAO,EAAE,cAAc,EACvB,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,cAAc,CAAC;IAiG1B,OAAO,CAAC,aAAa;YAgBP,UAAU;IAyBxB,OAAO,CAAC,gBAAgB;YAaV,cAAc;YAkBd,eAAe;YAsBf,qBAAqB;YA6BrB,cAAc;YAcd,uBAAuB;YAcvB,gBAAgB;YA6BhB,eAAe;IAmB7B;;;;;;;;;;;;OAYG;YACW,mBAAmB;YA6BnB,cAAc;YAad,SAAS;YAiBT,qBAAqB;YAoBrB,UAAU;CA+J3B;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAM7C"}
@@ -12,13 +12,16 @@ import { BaseActivitySyncProvider } from './BaseActivitySyncProvider.js';
12
12
  import { ACTIVITY_SYNC_ENTITIES } from './entity-names.js';
13
13
  import { ExtensionsExtraFilter, RunRegisteredExtensions, } from './extensions.js';
14
14
  import { IdentityResolver } from './identity.js';
15
+ import { ParseInternalDomains, ParticipantScopeWarning } from './participants.js';
16
+ import { AttachmentPolicyFor, HostActivityFileSink } from './attachments.js';
15
17
  import { DefaultDeterministicStages, } from './stages.js';
16
18
  import { FixtureActivitySyncProvider } from './providers/FixtureActivitySyncProvider.js';
17
19
  import { MSGraphActivitySyncProvider } from './providers/MSGraphActivitySyncProvider.js';
18
20
  import { MSGraphCalendarSyncProvider } from './providers/MSGraphCalendarSyncProvider.js';
19
21
  import { DefaultPolicyFromProviderType, RunQualificationCascade, } from './qualification.js';
20
- import { AsDryRunDecision, IsConnectionActive, } from './run.js';
21
- import { RequireUUID } from './sql.js';
22
+ import { AsDryRunDecision, IsConnectionActive, ResolveCapturePlan, ResolvePolicy, } from './run.js';
23
+ import { ContentToCapture, HostActivityContentCipher, } from './content-capture.js';
24
+ import { RequireUUID, UuidInList } from './sql.js';
22
25
  import { CanAdvanceWatermark, MergeCalendarWatermark, NextWatermark, SurfaceWatermark, } from './watermark.js';
23
26
  import { ExclusionsExtraFilter, FromRunView, RulesExtraFilter } from './load.js';
24
27
  import { ActivityWriter, StoreBodyFromSettings } from './writer.js';
@@ -27,13 +30,19 @@ export const MAX_RUNNABLE_CONNECTIONS = 500;
27
30
  /**
28
31
  * LastError must name the failure, not whatever happened to be Issues[0].
29
32
  * Mapping warnings ("Event X had no usable start time") sort ahead of the actual miss.
33
+ *
34
+ * NOT TRUNCATED. This used to end `.slice(0, 4000)`, the same inherited habit removed from the run's
35
+ * issue list: `ActivitySyncConnection.LastError` is NVARCHAR(MAX), and none of the columns this app
36
+ * writes free text to is 4000 wide. It flattens the issues of EVERY failed surface, so it grows with
37
+ * the number of failures — a run that fails broadly truncates its own diagnosis, which is the one
38
+ * occasion the text is worth reading in full.
30
39
  */
31
40
  export function healthErrorFromResults(results) {
32
41
  const failed = results.filter((r) => !r.Success);
33
42
  if (failed.length === 0)
34
43
  return null;
35
44
  const issues = failed.flatMap((r) => r.Issues).filter((m) => m.trim().length > 0);
36
- return (issues.join(' | ') || 'Activity sync run failed.').slice(0, 4000);
45
+ return issues.join(' | ') || 'Activity sync run failed.';
37
46
  }
38
47
  /**
39
48
  * One Load+Save per extension row after the batch — not N items × M extensions.
@@ -69,11 +78,52 @@ function failedSurfaceResult(issues) {
69
78
  Issues: [...issues],
70
79
  };
71
80
  }
81
+ /**
82
+ * The driver class of the SURFACE being run, which is not always the connection's.
83
+ *
84
+ * `RunConnections` drives a second, calendar surface from the same connection and the same type row,
85
+ * passing the calendar plugin as `source`. Handing `typeRow.DriverClass` to both told a host factory
86
+ * "Microsoft365" on the calendar pass too, so a factory serving both surfaces could not tell them
87
+ * apart: it built a MAIL transport for the calendar, fed Graph message payloads to the event mapper,
88
+ * and every one was dropped for having no start time. That reads as an empty calendar, not as a
89
+ * wiring fault — which is why it survived until a calendar fixture ran end to end.
90
+ *
91
+ * Pure and exported so the mapping can be pinned without standing up a fleet run.
92
+ */
93
+ export function SurfaceDriverClass(kind, typeRow, fallback) {
94
+ const declared = kind === 'Calendar' ? typeRow?.CalendarDriverClass : typeRow?.DriverClass;
95
+ // A blank column is not a driver. Falling through to the plugin's own code keeps a
96
+ // half-configured provider type working as it did rather than serving an empty string.
97
+ return declared?.trim() ? declared.trim() : fallback;
98
+ }
72
99
  export class ActivitySyncEngine {
73
- constructor(resolver = new IdentityResolver(), writer = new ActivityWriter(), stages = DefaultDeterministicStages()) {
100
+ constructor(resolver = new IdentityResolver(), writer = new ActivityWriter(), stages = DefaultDeterministicStages(),
101
+ /**
102
+ * Where attachment BYTES go, when a rule asks for them.
103
+ *
104
+ * Optional and injected rather than imported: storing a file needs MJ's FileStorageEngine and
105
+ * a configured FileStorageAccount, and a host that syncs only metadata should not have to
106
+ * have either. Absent, an item whose rule wants attachments is reported rather than quietly
107
+ * filed without them — the distinction this package exists to keep.
108
+ *
109
+ * DEFAULTS TO THE HOST REGISTRY, because the only production construction of this class is
110
+ * `new ActivitySyncEngine()` inside an Action, where nothing can pass one. Without that
111
+ * default a host could implement the interface and still never be called.
112
+ */
113
+ fileSink = HostActivityFileSink(),
114
+ /**
115
+ * How captured content is protected, when a policy says to keep any.
116
+ *
117
+ * Same shape and same reason as `fileSink`: this package implements no crypto, and the only
118
+ * production construction of this class passes no arguments, so the default has to come from
119
+ * the host registry or the seam is unreachable. `common-server` fills it at bootstrap.
120
+ */
121
+ cipher = HostActivityContentCipher()) {
74
122
  this.resolver = resolver;
75
123
  this.writer = writer;
76
124
  this.stages = stages;
125
+ this.fileSink = fileSink;
126
+ this.cipher = cipher;
77
127
  }
78
128
  async Run(connectionID, options, provider, contextUser, source, surface) {
79
129
  const stampHealth = surface?.stampHealth ?? true;
@@ -127,6 +177,36 @@ export class ActivitySyncEngine {
127
177
  result.Issues.push(`Provider type '${typeRow.Code}' is not active.`);
128
178
  return result;
129
179
  }
180
+ /**
181
+ * AUDIT RETENTION IS SETTLED BEFORE ANYTHING IS READ, not at persist time.
182
+ *
183
+ * `ResolveCapturePlan` refuses a policy above `None` with no key, and refusing AFTER a
184
+ * mailbox has been read is the wrong order: it costs a fetch, and the run then has content it
185
+ * has been told it may not keep. Deciding here means a misconfigured connection stops before
186
+ * it touches anyone's mail.
187
+ *
188
+ * Failing the run rather than reporting and carrying on is what the policy asks for. An
189
+ * operator who set this asked for the record to exist; producing runs that look successful
190
+ * while retaining nothing is the failure this subsystem is written against, and it is exactly
191
+ * what happened before this was wired at all.
192
+ */
193
+ let capture;
194
+ try {
195
+ capture = ResolveCapturePlan(ResolvePolicy(typeRow?.DefaultSkippedContentPolicy ?? 'None', connection.SkippedContentPolicy), connection.EncryptionKeyID ?? typeRow?.DefaultEncryptionKeyID ?? null);
196
+ }
197
+ catch (err) {
198
+ result.Issues.push(String(err instanceof Error ? err.message : err));
199
+ return result;
200
+ }
201
+ if (capture.Capture !== 'None' && !this.cipher) {
202
+ // The ActivityFileSink lesson, applied. A host that asked for retention and cannot
203
+ // encrypt must not quietly proceed: plaintext is forbidden outright, and writing nothing
204
+ // would leave the operator believing an audit trail exists.
205
+ result.Issues.push(`SkippedContentPolicy is "${capture.Capture === 'Subject' ? 'SubjectEncrypted' : 'FullEncrypted'}" ` +
206
+ 'but this host registered no content cipher, so captured content could not be encrypted. ' +
207
+ 'Call RegisterActivityContentCipher() at bootstrap, or set the policy to "None".');
208
+ return result;
209
+ }
130
210
  // Missing type row: the cascade still needs a default, and that default is
131
211
  // Exclude — never `?? 'Include'`.
132
212
  const defaultPolicy = DefaultPolicyFromProviderType(typeRow?.DefaultQualificationPolicy);
@@ -140,10 +220,21 @@ export class ActivitySyncEngine {
140
220
  // Tell the plugin which connection this run is for BEFORE it fetches. This is the only
141
221
  // moment it can learn which credential the connection named: ClassFactory builds plugins
142
222
  // with no arguments, so nothing is injectable at construction.
223
+ // THE DRIVER CLASS OF THE SURFACE BEING RUN, not of the connection.
224
+ //
225
+ // `RunConnections` drives a second, CALENDAR surface from the same connection and the same
226
+ // type row, passing the calendar plugin as `source`. Handing `typeRow.DriverClass` to both
227
+ // told a host factory "Microsoft365" for the calendar pass as well, so a factory serving both
228
+ // surfaces could not tell them apart and built a MAIL transport for the calendar — which then
229
+ // fed Graph message payloads to the event mapper, and every one was dropped for having no
230
+ // start time. It read as an empty calendar rather than as a wiring fault.
231
+ //
232
+ // The plugin already knows which surface it is; that is what `Kind` is for.
233
+ const surfaceDriver = SurfaceDriverClass(plugin.Kind, typeRow, plugin.ProviderTypeCode);
143
234
  plugin.Configure({
144
235
  CredentialsRef: connection.CredentialsRef ?? null,
145
236
  Mailbox: connection.Mailbox ?? null,
146
- DriverClass: typeRow?.DriverClass ?? plugin.ProviderTypeCode,
237
+ DriverClass: surfaceDriver,
147
238
  ContextUser: contextUser,
148
239
  });
149
240
  const sourceSystem = typeRow?.Code ?? plugin.ProviderTypeCode;
@@ -174,13 +265,27 @@ export class ActivitySyncEngine {
174
265
  if (rules.Failed) {
175
266
  return this.failClosed(connection, options, result, since, provider, contextUser, rules.Issue, stampHealth);
176
267
  }
268
+ const internalDomains = await this.loadInternalDomains(bound.Rows, contextUser);
269
+ if (internalDomains.Failed) {
270
+ return this.failClosed(connection, options, result, since, provider, contextUser, internalDomains.Issue, stampHealth);
271
+ }
272
+ // A rule that tests participants against NO domain list does not filter — it INVERTS.
273
+ // `ClassifyParticipants` counts an address as Internal only when its domain is in the list,
274
+ // so an empty list makes every participant External: `HasExternal` matches everything,
275
+ // including the purely internal chatter it exists to keep out, and `AllInternal` matches
276
+ // nothing. That reads as a working filter and is the opposite of one, so it is reported
277
+ // rather than left to look like a quiet pass.
278
+ const scopeWarning = ParticipantScopeWarning(rules.Rows, internalDomains.Rows);
279
+ if (scopeWarning) {
280
+ result.Issues.push(scopeWarning);
281
+ }
177
282
  const allParticipants = batch.Items.flatMap((i) => i.Participants);
178
283
  const extensionStamps = [];
179
284
  const identities = await this.resolver.Resolve(allParticipants, contextUser);
180
285
  if (identities.LookupFailed) {
181
286
  result.Failed += batch.Items.length;
182
287
  result.Issues.push('ContactMethod lookup failed — watermark will not advance.');
183
- await this.persistRun(connection, options, result, since, null, provider, contextUser, []);
288
+ await this.persistRun(connection, options, result, since, null, provider, contextUser, capture, []);
184
289
  if (!options.DryRun && stampHealth) {
185
290
  await this.stampConnectionHealth(connection.ID, false, 'ContactMethod lookup failed — watermark will not advance.', contextUser, provider);
186
291
  }
@@ -193,7 +298,7 @@ export class ActivitySyncEngine {
193
298
  ProviderTypeCode: sourceSystem,
194
299
  Exclusions: exclusions.Rows,
195
300
  Rules: rules.Rows,
196
- InternalDomains: [],
301
+ InternalDomains: internalDomains.Rows,
197
302
  KnownAddresses: identities.Known,
198
303
  };
199
304
  let verdict;
@@ -255,6 +360,35 @@ export class ActivitySyncEngine {
255
360
  });
256
361
  continue;
257
362
  }
363
+ // ATTACHMENTS, decided from the rule that actually decided this item.
364
+ //
365
+ // `ActivitySyncRule.IncludeAttachments` and `MaxAttachmentBytes` had no reader at all:
366
+ // a rule that asked for attachments got none and said nothing. The decision is made
367
+ // here, where both the winning rule and the item are in scope for the first time.
368
+ //
369
+ // The BYTES are not moved yet — that needs a file sink, and this host has no
370
+ // FileStorageAccount configured, so there is nowhere to put them. What changed is that
371
+ // the request is now honoured or REPORTED, instead of silently discarded.
372
+ const decidingRule = verdict.ActivitySyncRuleID
373
+ ? rules.Rows.find((r) => r.ID === verdict.ActivitySyncRuleID)
374
+ : null;
375
+ const attachmentPolicy = AttachmentPolicyFor(decidingRule, item);
376
+ if (attachmentPolicy.Fetch && !this.fileSink) {
377
+ result.Issues.push(`Item ${item.ExternalID}: its rule asks for attachments, but no ActivityFile sink is ` +
378
+ 'registered in this host, so none were stored. Register one at bootstrap, or turn ' +
379
+ 'IncludeAttachments off so the rule stops claiming something that is not happening.');
380
+ }
381
+ else if (attachmentPolicy.Fetch) {
382
+ // A sink IS registered, and `ActivityFileSink.Store` still has no caller: selection and
383
+ // transfer are written (`SelectAttachments`, `AttachmentSkipReport`) but not yet wired to
384
+ // it. Saying so is the entire point of the branch above — leaving this case silent would
385
+ // reward a host for filling the seam correctly with exactly the quiet nothing that the
386
+ // rest of this work exists to remove, and it is the more misleading of the two, because
387
+ // everything on the host's side is right.
388
+ result.Issues.push(`Item ${item.ExternalID}: its rule asks for attachments and a sink is registered, but ` +
389
+ 'attachment transfer is not implemented yet, so none were stored. This is a gap in ' +
390
+ 'Activity Sync, not in the host configuration.');
391
+ }
258
392
  const sourceValue = plugin.IsLive ? 'Integration' : 'System';
259
393
  const written = await this.writer.Write({
260
394
  Item: item,
@@ -326,7 +460,7 @@ export class ActivitySyncEngine {
326
460
  result.Issues.push('Failed to persist the surface watermark — it will not advance.');
327
461
  }
328
462
  }
329
- await this.persistRun(connection, options, result, since, options.DryRun ? null : result.WatermarkAdvancedTo, provider, contextUser, details);
463
+ await this.persistRun(connection, options, result, since, options.DryRun ? null : result.WatermarkAdvancedTo, provider, contextUser, capture, details);
330
464
  result.Success = result.Failed === 0;
331
465
  if (!options.DryRun && stampHealth) {
332
466
  await this.stampConnectionHealth(connectionID, result.Success, healthErrorFromResults([result]), contextUser, provider);
@@ -382,9 +516,12 @@ export class ActivitySyncEngine {
382
516
  typeRow,
383
517
  });
384
518
  fleet.Results.push({ ConnectionID: connection.ID, Surface: 'primary', Result: primary });
519
+ // Issues travel whether or not the surface succeeded; only `Success` keys on failure. The
520
+ // previous shape meant a caller inspecting `fleet.Issues` saw nothing from a run that
521
+ // completed with warnings, which is most of what this engine has to say.
522
+ fleet.Issues.push(...primary.Issues);
385
523
  if (!primary.Success) {
386
524
  fleet.Success = false;
387
- fleet.Issues.push(...primary.Issues);
388
525
  }
389
526
  const surfaces = [primary];
390
527
  const calendarDriver = typeRow?.CalendarDriverClass?.trim();
@@ -407,9 +544,10 @@ export class ActivitySyncEngine {
407
544
  source: calendarPlugin,
408
545
  });
409
546
  fleet.Results.push({ ConnectionID: connection.ID, Surface: 'Calendar', Result: calendar });
547
+ // Same as the primary surface above: issues travel regardless of success.
548
+ fleet.Issues.push(...calendar.Issues);
410
549
  if (!calendar.Success) {
411
550
  fleet.Success = false;
412
- fleet.Issues.push(...calendar.Issues);
413
551
  }
414
552
  surfaces.push(calendar);
415
553
  }
@@ -440,7 +578,12 @@ export class ActivitySyncEngine {
440
578
  result.Failed += result.Fetched;
441
579
  if (result.Failed < 1)
442
580
  result.Failed = 1;
443
- await this.persistRun(connection, options, result, since, null, provider, contextUser, []);
581
+ // No capture plan, and none needed: this path writes ZERO run details, so there is no row for
582
+ // content to land on. Reaching for the connection's real policy here would be a decision
583
+ // dressed up as caution — this method exists to record a run that never got as far as
584
+ // deciding anything about a message.
585
+ const noCapture = { Capture: 'None', EncryptionKeyID: null };
586
+ await this.persistRun(connection, options, result, since, null, provider, contextUser, noCapture, []);
444
587
  if (!options.DryRun && stampHealth) {
445
588
  await this.stampConnectionHealth(connection.ID, false, issue, contextUser, provider);
446
589
  }
@@ -495,7 +638,10 @@ export class ActivitySyncEngine {
495
638
  }
496
639
  else {
497
640
  row.Status = 'Error';
498
- row.LastError = (error ?? 'Activity sync run failed.').slice(0, 4000);
641
+ // Not truncated here either. Removing the cap from `healthErrorFromResults` and
642
+ // leaving it on the only write site would have changed nothing an operator can
643
+ // see — the column is NVARCHAR(MAX), and this is where the text lands.
644
+ row.LastError = error ?? 'Activity sync run failed.';
499
645
  }
500
646
  await row.Save();
501
647
  }
@@ -528,7 +674,21 @@ export class ActivitySyncEngine {
528
674
  const res = await rv.RunView({
529
675
  EntityName: ACTIVITY_SYNC_ENTITIES.ProviderTypes,
530
676
  ExtraFilter: `ID = '${RequireUUID(id, 'ActivitySyncProviderTypeID')}'`,
531
- Fields: ['ID', 'Code', 'DriverClass', 'DefaultQualificationPolicy', 'CalendarDriverClass', 'IsActive'],
677
+ // Every name here must match ProviderTypeRow exactly, both ways round. The docblock
678
+ // there is not decoration: a field declared and not listed reads `undefined`, and a
679
+ // capture policy that reads undefined silently means "None". A test pins the two
680
+ // lists against each other, and it parses this array literally -- keep comments out
681
+ // of it.
682
+ Fields: [
683
+ 'ID',
684
+ 'Code',
685
+ 'DriverClass',
686
+ 'DefaultQualificationPolicy',
687
+ 'CalendarDriverClass',
688
+ 'IsActive',
689
+ 'DefaultSkippedContentPolicy',
690
+ 'DefaultEncryptionKeyID',
691
+ ],
532
692
  MaxRows: 1,
533
693
  ResultType: 'simple',
534
694
  }, user);
@@ -549,6 +709,44 @@ export class ActivitySyncEngine {
549
709
  Rows: (bound.Results ?? []).map((r) => r.ActivitySyncRuleSetID),
550
710
  };
551
711
  }
712
+ /**
713
+ * The domains this deployment calls INTERNAL, merged across every rule set bound to the
714
+ * connection.
715
+ *
716
+ * WHY THIS EXISTS. `ActivitySyncRuleSet.InternalDomains` describes itself as "Required for any
717
+ * rule using ParticipantScope", `participants.ts` names it as where the list lives, and the
718
+ * engine passed a hard-coded `[]` — so nothing ever read the column. Same shape as the
719
+ * `CredentialsRef` gap: a column that documents its own purpose, with no reader.
720
+ *
721
+ * MALFORMED IS NOT EMPTY. A list that fails to parse fails the run rather than degrading to
722
+ * `[]`, because `[]` silently inverts every participant rule (see the caller). Parsing itself
723
+ * lives in {@link ParseInternalDomains} so it is testable without standing up a RunView.
724
+ */
725
+ async loadInternalDomains(setIds, user) {
726
+ if (setIds.length === 0) {
727
+ return { Failed: false, Rows: [] };
728
+ }
729
+ const rv = new RunView();
730
+ const res = await rv.RunView({
731
+ EntityName: ACTIVITY_SYNC_ENTITIES.RuleSets,
732
+ ExtraFilter: `ID IN (${UuidInList(setIds, 'ActivitySyncRuleSetID')})`,
733
+ Fields: ['ID', 'Name', 'InternalDomains'],
734
+ ResultType: 'simple',
735
+ }, user);
736
+ if (!res.Success) {
737
+ return { Failed: true, Issue: 'ActivitySyncRuleSet lookup failed.' };
738
+ }
739
+ const domains = new Set();
740
+ for (const row of res.Results ?? []) {
741
+ const parsed = ParseInternalDomains(row.InternalDomains, row.Name);
742
+ if (!parsed.Ok) {
743
+ return { Failed: true, Issue: parsed.Issue };
744
+ }
745
+ for (const d of parsed.Domains)
746
+ domains.add(d);
747
+ }
748
+ return { Failed: false, Rows: [...domains] };
749
+ }
552
750
  async loadExclusions(setIds, user) {
553
751
  const rv = new RunView();
554
752
  const res = await rv.RunView({
@@ -579,7 +777,7 @@ export class ActivitySyncEngine {
579
777
  }
580
778
  return row.Save();
581
779
  }
582
- async persistRun(connection, options, result, watermarkBefore, watermarkAfter, provider, user, details) {
780
+ async persistRun(connection, options, result, watermarkBefore, watermarkAfter, provider, user, capture, details) {
583
781
  try {
584
782
  const run = await provider.GetEntityObject(ACTIVITY_SYNC_ENTITIES.Runs, user);
585
783
  run.NewRecord();
@@ -597,6 +795,31 @@ export class ActivitySyncEngine {
597
795
  run.StartedAt = new Date();
598
796
  run.EndedAt = new Date();
599
797
  run.Status = result.Failed > 0 ? 'Failed' : 'Completed';
798
+ /**
799
+ * EVERY ISSUE IS RECORDED, INCLUDING ON A RUN THAT SUCCEEDED.
800
+ *
801
+ * This row used to keep none of them. `healthErrorFromResults` filters to `!r.Success` and
802
+ * the fleet collected issues only from failed surfaces, so a warning raised by a run that
803
+ * completed existed in an in-memory array and nowhere else. That silently discarded the
804
+ * ENTIRE delivery mechanism for several deliberate reports: the attachment gap a rule asked
805
+ * for and no sink could fill, the participant-scope warning, the capped-read notice, and the
806
+ * calendar's first-run lookback bound. Each was written to be seen, and none could be.
807
+ *
808
+ * The column is named ErrorMessage and these are not all errors. Recording them here is
809
+ * still right: it is the run's only free-text column, it is NVARCHAR(MAX), and a warning
810
+ * nobody can read is worth less than one filed under an imperfect name. Connection HEALTH
811
+ * stays keyed on failure — a warned run must not make a working connection look broken.
812
+ *
813
+ * NOT TRUNCATED, and neither is anything else this engine writes any more. Three writes
814
+ * capped free text against MAX columns — this one and `LastError` at 4000, the run detail's
815
+ * `Reason` at 500 — an inherited habit rather than a constraint, since none of the columns is
816
+ * that wide. It cost nothing while each held a single failure message. This one is the first
817
+ * that GROWS WITH THE ITEM COUNT: the attachment gap is reported once per item at roughly
818
+ * 250-320 characters, so a fifty-item run would lose most of its tail, in the field this
819
+ * commit added so those warnings could be read at all. The other two came off with it, and
820
+ * each has a mutant putting it back.
821
+ */
822
+ run.ErrorMessage = result.Issues.length > 0 ? result.Issues.join(' | ') : null;
600
823
  if (!(await run.Save())) {
601
824
  result.Issues.push(run.LatestResult?.CompleteMessage ?? 'ActivitySyncRun.Save failed.');
602
825
  return;
@@ -611,10 +834,67 @@ export class ActivitySyncEngine {
611
834
  row.OccurredAt = detail.Item.StartedAt;
612
835
  row.Decision = options.DryRun ? AsDryRunDecision(detail.Decision) : detail.Decision;
613
836
  row.DecidedByStage = detail.Stage;
614
- row.Reason = detail.Reason.slice(0, 500);
837
+ /**
838
+ * The third cap, and the same finding as the other two: `Reason` is NVARCHAR(MAX) and
839
+ * this sliced it at 500. It matters most for the decision this feature is about — a
840
+ * `Failed` detail's Reason is the writer's issues joined, and it sits beside
841
+ * `CapturedContent` as the explanation of why the message was not filed. Losing the tail
842
+ * of that leaves an audit row holding content and no usable account of the failure.
843
+ */
844
+ row.Reason = detail.Reason;
615
845
  row.ActivitySyncRuleID = detail.RuleID ?? null;
616
846
  row.ActivitySyncExclusionID = detail.ExclusionID ?? null;
617
847
  row.ActivityID = detail.Decision === 'Included' ? (detail.ActivityID ?? null) : null;
848
+ /**
849
+ * CAPTURED CONTENT, for messages this run declined to file.
850
+ *
851
+ * Only on a real skip. `Included` has an Activity carrying the content already, and a
852
+ * DRY RUN decided nothing — `WouldExclude` is a preview, and writing ciphertext for a
853
+ * message the engine has not actually declined would put real content behind a
854
+ * retention policy on the strength of a rehearsal.
855
+ *
856
+ * `Failed` is deliberately included: a message that could not be written is exactly
857
+ * the one an auditor asks about, and it is the case where nothing else holds a copy.
858
+ *
859
+ * ── `Duplicate` IS EXCLUDED, FOR THE SAME REASON AS `Included` ────────────────────
860
+ *
861
+ * A duplicate is filed. The writer reports `AlreadyPresent` and the detail carries
862
+ * `ActivityID`, so the content is already retrievable from the Activity holding it —
863
+ * which is precisely the argument that keeps `Included` out. Capturing it puts an
864
+ * encrypted second copy of ordinary filed mail in a column meant for messages that were
865
+ * NOT filed, and `row.ActivityID` is nulled for every non-`Included` decision, so that
866
+ * copy would not even link back to the Activity holding the same text.
867
+ *
868
+ * The cost is not theoretical. The calendar window ignores `Since` and is always
869
+ * `[now - 30d, now + 30d]`, so every run re-reads the whole window and every event
870
+ * already filed comes back a duplicate. A meeting sits in that window across roughly
871
+ * sixty daily runs: one files it, the rest would re-encrypt its subject and body onto a
872
+ * fresh run-detail row each time. Under `FullEncrypted` that is about sixty encrypted
873
+ * copies of every meeting.
874
+ *
875
+ * It was captured until golive#116's follow-up review asked whether that was deliberate.
876
+ * It was not.
877
+ *
878
+ * Ciphertext and key are written together or not at all, mirroring
879
+ * CK_ActivitySyncRunDetail_ContentKey. Encryption failing is reported and the row is
880
+ * still saved without content: losing the whole run record because one message could
881
+ * not be encrypted would be a worse trade than an audit gap that says so.
882
+ */
883
+ const skipped = detail.Decision === 'Excluded' || detail.Decision === 'Failed';
884
+ if (capture.Capture !== 'None' && skipped && !options.DryRun && this.cipher && capture.EncryptionKeyID) {
885
+ const plaintext = ContentToCapture(capture.Capture, detail.Item);
886
+ if (plaintext !== null) {
887
+ try {
888
+ row.CapturedContent = await this.cipher.Encrypt(plaintext, capture.EncryptionKeyID, user);
889
+ row.EncryptionKeyID = capture.EncryptionKeyID;
890
+ }
891
+ catch (err) {
892
+ result.Issues.push(`Could not encrypt captured content for ${detail.Item.ExternalID}: ` +
893
+ `${err instanceof Error ? err.message : String(err)}. The decision was recorded; ` +
894
+ 'the content was not.');
895
+ }
896
+ }
897
+ }
618
898
  if (!(await row.Save())) {
619
899
  result.Issues.push(row.LatestResult?.CompleteMessage ??
620
900
  `ActivitySyncRunDetail.Save failed for ${detail.Item.ExternalID}.`);