@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.
- package/dist/ActivitySyncEngine.d.ts +93 -1
- package/dist/ActivitySyncEngine.d.ts.map +1 -1
- package/dist/ActivitySyncEngine.js +295 -15
- package/dist/ActivitySyncEngine.js.map +1 -1
- package/dist/attachments.d.ts +106 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +147 -0
- package/dist/attachments.js.map +1 -0
- package/dist/content-capture.d.ts +92 -0
- package/dist/content-capture.d.ts.map +1 -0
- package/dist/content-capture.js +35 -0
- package/dist/content-capture.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/participants.d.ts +30 -0
- package/dist/participants.d.ts.map +1 -1
- package/dist/participants.js +48 -0
- package/dist/participants.js.map +1 -1
- package/dist/providers/GraphCalendarTransport.d.ts +115 -0
- package/dist/providers/GraphCalendarTransport.d.ts.map +1 -0
- package/dist/providers/GraphCalendarTransport.js +139 -0
- package/dist/providers/GraphCalendarTransport.js.map +1 -0
- package/dist/providers/GraphCommunicationTransport.d.ts.map +1 -1
- package/dist/providers/GraphCommunicationTransport.js +18 -4
- package/dist/providers/GraphCommunicationTransport.js.map +1 -1
- package/dist/providers/GraphMessageMapper.d.ts +2 -0
- package/dist/providers/GraphMessageMapper.d.ts.map +1 -1
- package/dist/providers/GraphMessageMapper.js +3 -0
- package/dist/providers/GraphMessageMapper.js.map +1 -1
- package/dist/providers/MSGraphActivitySyncProvider.d.ts +39 -4
- package/dist/providers/MSGraphActivitySyncProvider.d.ts.map +1 -1
- package/dist/providers/MSGraphActivitySyncProvider.js +45 -10
- package/dist/providers/MSGraphActivitySyncProvider.js.map +1 -1
- package/dist/providers/MSGraphCalendarSyncProvider.d.ts +55 -14
- package/dist/providers/MSGraphCalendarSyncProvider.d.ts.map +1 -1
- package/dist/providers/MSGraphCalendarSyncProvider.js +112 -38
- package/dist/providers/MSGraphCalendarSyncProvider.js.map +1 -1
- package/dist/providers/MessageTransport.d.ts +90 -0
- package/dist/providers/MessageTransport.d.ts.map +1 -1
- package/dist/providers/MessageTransport.js +72 -0
- package/dist/providers/MessageTransport.js.map +1 -1
- package/dist/providers/RecordedMessageTransport.d.ts.map +1 -1
- package/dist/providers/RecordedMessageTransport.js +25 -11
- package/dist/providers/RecordedMessageTransport.js.map +1 -1
- package/dist/stages.d.ts +11 -0
- package/dist/stages.d.ts.map +1 -1
- package/dist/stages.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|
-
|
|
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":"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;
|
|
1
|
+
{"version":3,"file":"ActivitySyncEngine.d.ts","sourceRoot":"","sources":["../src/ActivitySyncEngine.ts"],"names":[],"mappings":"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"}
|
|
@@ -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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}.`);
|