@rapidmx/activesync-plugin 1.0.0-beta.2 → 1.0.0-beta.3
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/README.md +4 -4
- package/dist/lib/BaseDeviceSyncStateRoute.js +32 -0
- package/dist/lib/BaseDeviceSyncStateRoute.js.map +1 -1
- package/dist/lib/BaseEasRoute.js +91 -15
- package/dist/lib/BaseEasRoute.js.map +1 -1
- package/dist/lib/EasAuditLog.js +56 -0
- package/dist/lib/EasAuditLog.js.map +1 -0
- package/dist/lib/EasCollectionLease.js +199 -0
- package/dist/lib/EasCollectionLease.js.map +1 -0
- package/dist/lib/EasCollectionStore.js +102 -0
- package/dist/lib/EasCollectionStore.js.map +1 -0
- package/dist/lib/EasCollectionSync.js +294 -0
- package/dist/lib/EasCollectionSync.js.map +1 -0
- package/dist/lib/EasSyncKeyUtils.js +125 -62
- package/dist/lib/EasSyncKeyUtils.js.map +1 -1
- package/dist/lib/MessageMoveRules.js +100 -0
- package/dist/lib/MessageMoveRules.js.map +1 -0
- package/dist/lib/MimeHeaderUtils.js +395 -0
- package/dist/lib/MimeHeaderUtils.js.map +1 -0
- package/dist/lib/RegexPatternUtils.js +30 -0
- package/dist/lib/RegexPatternUtils.js.map +1 -0
- package/dist/lib/RestapiCompat.js +41 -0
- package/dist/lib/RestapiCompat.js.map +1 -0
- package/dist/lib/adapters/CalendarSyncAdapter.js +142 -33
- package/dist/lib/adapters/CalendarSyncAdapter.js.map +1 -1
- package/dist/lib/adapters/EmailSyncAdapter.js +114 -27
- package/dist/lib/adapters/EmailSyncAdapter.js.map +1 -1
- package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js +2 -1
- package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js.map +1 -1
- package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js +2 -1
- package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js.map +1 -1
- package/dist/lib/codec/WbxmlDecoder.js +86 -34
- package/dist/lib/codec/WbxmlDecoder.js.map +1 -1
- package/dist/lib/codec/WbxmlEncoder.js +103 -22
- package/dist/lib/codec/WbxmlEncoder.js.map +1 -1
- package/dist/lib/commands/ComposeMailCommand.js +105 -31
- package/dist/lib/commands/ComposeMailCommand.js.map +1 -1
- package/dist/lib/commands/FolderSyncCommand.js +67 -27
- package/dist/lib/commands/FolderSyncCommand.js.map +1 -1
- package/dist/lib/commands/GetItemEstimateCommand.js +80 -59
- package/dist/lib/commands/GetItemEstimateCommand.js.map +1 -1
- package/dist/lib/commands/ItemOperationsCommand.js +219 -56
- package/dist/lib/commands/ItemOperationsCommand.js.map +1 -1
- package/dist/lib/commands/MeetingResponseCommand.js +212 -53
- package/dist/lib/commands/MeetingResponseCommand.js.map +1 -1
- package/dist/lib/commands/MoveItemsCommand.js +54 -21
- package/dist/lib/commands/MoveItemsCommand.js.map +1 -1
- package/dist/lib/commands/PingCommand.js +288 -56
- package/dist/lib/commands/PingCommand.js.map +1 -1
- package/dist/lib/commands/ProvisionCommand.js +34 -17
- package/dist/lib/commands/ProvisionCommand.js.map +1 -1
- package/dist/lib/commands/ResolveRecipientsCommand.js +43 -5
- package/dist/lib/commands/ResolveRecipientsCommand.js.map +1 -1
- package/dist/lib/commands/SearchCommand.js +92 -17
- package/dist/lib/commands/SearchCommand.js.map +1 -1
- package/dist/lib/commands/SettingsCommand.js +2 -1
- package/dist/lib/commands/SettingsCommand.js.map +1 -1
- package/dist/lib/commands/SmartForwardCommand.js +2 -1
- package/dist/lib/commands/SmartForwardCommand.js.map +1 -1
- package/dist/lib/commands/SmartReplyCommand.js +2 -1
- package/dist/lib/commands/SmartReplyCommand.js.map +1 -1
- package/dist/lib/commands/SyncCommand.js +481 -244
- package/dist/lib/commands/SyncCommand.js.map +1 -1
- package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js +6 -1
- package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js +3 -1
- package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/PingCommandMongo.js +23 -0
- package/dist/lib/commands/mongo/PingCommandMongo.js.map +1 -0
- package/dist/lib/commands/mongo/SearchCommandMongo.js +3 -1
- package/dist/lib/commands/mongo/SearchCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SendMailCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/SendMailCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SmartForwardCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/SmartForwardCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SmartReplyCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/SmartReplyCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SyncCommandMongo.js +7 -1
- package/dist/lib/commands/mongo/SyncCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/index.js +1 -0
- package/dist/lib/commands/mongo/index.js.map +1 -1
- package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js +6 -1
- package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/ItemOperationsCommandSQL.js +3 -1
- package/dist/lib/commands/sql/ItemOperationsCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/MeetingResponseCommandSQL.js +2 -1
- package/dist/lib/commands/sql/MeetingResponseCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/PingCommandSQL.js +23 -0
- package/dist/lib/commands/sql/PingCommandSQL.js.map +1 -0
- package/dist/lib/commands/sql/SearchCommandSQL.js +3 -1
- package/dist/lib/commands/sql/SearchCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SendMailCommandSQL.js +2 -1
- package/dist/lib/commands/sql/SendMailCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SmartForwardCommandSQL.js +2 -1
- package/dist/lib/commands/sql/SmartForwardCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SmartReplyCommandSQL.js +2 -1
- package/dist/lib/commands/sql/SmartReplyCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SyncCommandSQL.js +7 -1
- package/dist/lib/commands/sql/SyncCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/index.js +1 -0
- package/dist/lib/commands/sql/index.js.map +1 -1
- package/dist/lib/index.js +5 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/jobs/EasDeviceStateCleanupJob.js +44 -2
- package/dist/lib/jobs/EasDeviceStateCleanupJob.js.map +1 -1
- package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js +4 -0
- package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js.map +1 -1
- package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js +9 -0
- package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js.map +1 -1
- package/dist/lib/models/EasCollectionChunk.js +2 -0
- package/dist/lib/models/EasCollectionChunk.js.map +1 -0
- package/dist/lib/models/EasCollectionState.js +2 -0
- package/dist/lib/models/EasCollectionState.js.map +1 -0
- package/dist/lib/models/mongo/DeviceSyncStateMongo.js +7 -0
- package/dist/lib/models/mongo/DeviceSyncStateMongo.js.map +1 -1
- package/dist/lib/models/mongo/EasCollectionChunkMongo.js +83 -0
- package/dist/lib/models/mongo/EasCollectionChunkMongo.js.map +1 -0
- package/dist/lib/models/mongo/EasCollectionStateMongo.js +162 -0
- package/dist/lib/models/mongo/EasCollectionStateMongo.js.map +1 -0
- package/dist/lib/models/sql/DeviceSyncStateSQL.js +7 -0
- package/dist/lib/models/sql/DeviceSyncStateSQL.js.map +1 -1
- package/dist/lib/models/sql/EasCollectionChunkSQL.js +83 -0
- package/dist/lib/models/sql/EasCollectionChunkSQL.js.map +1 -0
- package/dist/lib/models/sql/EasCollectionStateSQL.js +162 -0
- package/dist/lib/models/sql/EasCollectionStateSQL.js.map +1 -0
- package/dist/lib/mongo/EasRouteMongo.js +2 -2
- package/dist/lib/mongo/EasRouteMongo.js.map +1 -1
- package/dist/lib/mongo.js +2 -0
- package/dist/lib/mongo.js.map +1 -1
- package/dist/lib/sql/EasRouteSQL.js +2 -2
- package/dist/lib/sql/EasRouteSQL.js.map +1 -1
- package/dist/lib/sql.js +2 -0
- package/dist/lib/sql.js.map +1 -1
- package/dist/types/BaseDeviceSyncStateRoute.d.ts +10 -0
- package/dist/types/BaseEasRoute.d.ts +15 -4
- package/dist/types/EasAuditLog.d.ts +47 -0
- package/dist/types/EasCollectionLease.d.ts +60 -0
- package/dist/types/EasCollectionStore.d.ts +51 -0
- package/dist/types/EasCollectionSync.d.ts +96 -0
- package/dist/types/EasCommandHandler.d.ts +7 -4
- package/dist/types/EasSyncKeyUtils.d.ts +73 -42
- package/dist/types/MessageMoveRules.d.ts +45 -0
- package/dist/types/MimeHeaderUtils.d.ts +73 -0
- package/dist/types/RegexPatternUtils.d.ts +14 -0
- package/dist/types/RestapiCompat.d.ts +24 -0
- package/dist/types/adapters/CalendarSyncAdapter.d.ts +47 -18
- package/dist/types/adapters/EasCollectionSyncAdapter.d.ts +18 -2
- package/dist/types/adapters/EmailSyncAdapter.d.ts +24 -7
- package/dist/types/adapters/mongo/EmailSyncAdapterMongo.d.ts +1 -0
- package/dist/types/adapters/sql/EmailSyncAdapterSQL.d.ts +1 -0
- package/dist/types/codec/WbxmlDecoder.d.ts +39 -0
- package/dist/types/codec/WbxmlEncoder.d.ts +36 -1
- package/dist/types/commands/ComposeMailCommand.d.ts +33 -15
- package/dist/types/commands/FolderSyncCommand.d.ts +4 -1
- package/dist/types/commands/GetItemEstimateCommand.d.ts +20 -15
- package/dist/types/commands/ItemOperationsCommand.d.ts +29 -4
- package/dist/types/commands/MeetingResponseCommand.d.ts +52 -13
- package/dist/types/commands/MoveItemsCommand.d.ts +13 -3
- package/dist/types/commands/PingCommand.d.ts +72 -14
- package/dist/types/commands/ProvisionCommand.d.ts +16 -11
- package/dist/types/commands/ResolveRecipientsCommand.d.ts +4 -0
- package/dist/types/commands/SearchCommand.d.ts +13 -1
- package/dist/types/commands/SyncCommand.d.ts +102 -62
- package/dist/types/commands/mongo/GetItemEstimateCommandMongo.d.ts +3 -0
- package/dist/types/commands/mongo/ItemOperationsCommandMongo.d.ts +2 -0
- package/dist/types/commands/mongo/MeetingResponseCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/PingCommandMongo.d.ts +8 -0
- package/dist/types/commands/mongo/SearchCommandMongo.d.ts +2 -0
- package/dist/types/commands/mongo/SendMailCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/SmartForwardCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/SmartReplyCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/SyncCommandMongo.d.ts +4 -0
- package/dist/types/commands/mongo/index.d.ts +1 -0
- package/dist/types/commands/sql/GetItemEstimateCommandSQL.d.ts +3 -0
- package/dist/types/commands/sql/ItemOperationsCommandSQL.d.ts +2 -0
- package/dist/types/commands/sql/MeetingResponseCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/PingCommandSQL.d.ts +8 -0
- package/dist/types/commands/sql/SearchCommandSQL.d.ts +2 -0
- package/dist/types/commands/sql/SendMailCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/SmartForwardCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/SmartReplyCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/SyncCommandSQL.d.ts +4 -0
- package/dist/types/commands/sql/index.d.ts +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/jobs/EasDeviceStateCleanupJob.d.ts +15 -0
- package/dist/types/jobs/mongo/EasDeviceStateCleanupJobMongo.d.ts +2 -0
- package/dist/types/jobs/sql/EasDeviceStateCleanupJobSQL.d.ts +4 -0
- package/dist/types/models/DeviceSyncState.d.ts +13 -2
- package/dist/types/models/EasCollectionChunk.d.ts +20 -0
- package/dist/types/models/EasCollectionState.d.ts +79 -0
- package/dist/types/models/mongo/DeviceSyncStateMongo.d.ts +1 -0
- package/dist/types/models/mongo/EasCollectionChunkMongo.d.ts +16 -0
- package/dist/types/models/mongo/EasCollectionStateMongo.d.ts +27 -0
- package/dist/types/models/sql/DeviceSyncStateSQL.d.ts +1 -0
- package/dist/types/models/sql/EasCollectionChunkSQL.d.ts +16 -0
- package/dist/types/models/sql/EasCollectionStateSQL.d.ts +27 -0
- package/dist/types/mongo.d.ts +2 -0
- package/dist/types/sql.d.ts +2 -0
- package/package.json +267 -238
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/RapidMX/activesync/actions/workflows/build.yml)
|
|
4
4
|
[](https://coveralls.io/github/RapidMX/activesync?branch=main)
|
|
5
|
-
[](https://www.npmjs.com/package/@rapidmx/activesync)
|
|
5
|
+
[](https://www.npmjs.com/package/@rapidmx/activesync-plugin)
|
|
6
6
|
|
|
7
7
|
Exchange ActiveSync (EAS) protocol support for a [`@rapidmx/restapi`](https://github.com/RapidMX/restapi)-based
|
|
8
8
|
mail server — covers every command a real mobile client (iOS Mail, Outlook mobile, Android/Samsung Mail) needs
|
|
@@ -23,7 +23,7 @@ EAS-specific login flow — which means a real native device (rather than a test
|
|
|
23
23
|
token) needs an OAuth 2.0 Authorization Server role in front of it to obtain one; that piece is tracked as a
|
|
24
24
|
follow-up in `@rapidrest/auth`, not this package.
|
|
25
25
|
|
|
26
|
-
A [`@rapidmx/autodiscover`](https://github.com/RapidMX/autodiscover) mount lets real clients find this
|
|
26
|
+
A [`@rapidmx/autodiscover-plugin`](https://github.com/RapidMX/autodiscover) mount lets real clients find this
|
|
27
27
|
package's endpoint from just an email address.
|
|
28
28
|
|
|
29
29
|
Responds to a client's `OPTIONS` capability probe with real `MS-ASProtocolVersions`/`MS-ASProtocolCommands`
|
|
@@ -36,11 +36,11 @@ means `OPTIONS` falls back to the bare `204` and a client falls back to trying i
|
|
|
36
36
|
|
|
37
37
|
## Usage
|
|
38
38
|
|
|
39
|
-
Mount `EasRouteMongo`/`EasRouteSQL` (from `@rapidmx/activesync/mongo` or `/sql`) at the protocol's well-known
|
|
39
|
+
Mount `EasRouteMongo`/`EasRouteSQL` (from `@rapidmx/activesync-plugin/mongo` or `/sql`) at the protocol's well-known
|
|
40
40
|
path with a one-line subclass:
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
|
-
import { EasRouteMongo } from "@rapidmx/activesync/mongo";
|
|
43
|
+
import { EasRouteMongo } from "@rapidmx/activesync-plugin/mongo";
|
|
44
44
|
import { RouteDecorators } from "@rapidrest/service-core";
|
|
45
45
|
const { Route } = RouteDecorators;
|
|
46
46
|
|
|
@@ -30,6 +30,11 @@ const { Auth, Param, Post, Request, User: AuthUser } = RouteDecorators;
|
|
|
30
30
|
* existing 449 gate, at which point `ProvisionCommand.issuePolicy` sees the flag and sends the device a
|
|
31
31
|
* `RemoteWipe` directive instead of a normal policy document - no separate push/notification channel is needed.
|
|
32
32
|
*
|
|
33
|
+
* **Scope**: a `DeviceSyncState` row is one (mailbox, client-supplied `DeviceId`) pairing, so the wipe and the block
|
|
34
|
+
* that follows its acknowledgement apply to that `DeviceId` only. A compliant client wipes itself; a client that
|
|
35
|
+
* ignores the directive can pair again under a different `DeviceId` with the same credentials. Cutting such a client
|
|
36
|
+
* off requires revoking the account's credentials - not something this route does.
|
|
37
|
+
*
|
|
33
38
|
* `deviceSyncStateClass` is supplied by the Mongo/SQL concrete subclasses, the same one-line-per-backend
|
|
34
39
|
* pattern used throughout this library.
|
|
35
40
|
*
|
|
@@ -69,9 +74,27 @@ export class BaseDeviceSyncStateRoute {
|
|
|
69
74
|
remoteWipeRequested: true,
|
|
70
75
|
remoteWipeAccountOnly: accountOnly,
|
|
71
76
|
provisioned: false,
|
|
77
|
+
// The old key must stop working immediately, not just once the device next provisions.
|
|
78
|
+
policyKey: null,
|
|
72
79
|
});
|
|
73
80
|
return deviceSyncState;
|
|
74
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Lets a device that acknowledged a remote wipe (`blocked`, see `ProvisionCommand`) connect again: it must still
|
|
84
|
+
* complete a fresh Provision handshake. Admin-only, like `remoteWipe`.
|
|
85
|
+
*/
|
|
86
|
+
async unblock(uid, user) {
|
|
87
|
+
if (!user || !UserUtils.hasRoles(user, this.trustedRoles)) {
|
|
88
|
+
throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
|
|
89
|
+
}
|
|
90
|
+
const repo = await this.getDeviceSyncStateRepo();
|
|
91
|
+
const deviceSyncState = await repo.findOne(uid, { ignoreACL: true });
|
|
92
|
+
if (!deviceSyncState) {
|
|
93
|
+
throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
|
|
94
|
+
}
|
|
95
|
+
await persistDeviceSyncState(deviceSyncState, repo, { blocked: false });
|
|
96
|
+
return deviceSyncState;
|
|
97
|
+
}
|
|
75
98
|
}
|
|
76
99
|
__decorate([
|
|
77
100
|
Config("trusted_roles", ["admin"]),
|
|
@@ -87,4 +110,13 @@ __decorate([
|
|
|
87
110
|
__metadata("design:paramtypes", [String, Object, Object]),
|
|
88
111
|
__metadata("design:returntype", Promise)
|
|
89
112
|
], BaseDeviceSyncStateRoute.prototype, "remoteWipe", null);
|
|
113
|
+
__decorate([
|
|
114
|
+
Auth(["jwt"]),
|
|
115
|
+
Post("/:uid/unblock"),
|
|
116
|
+
__param(0, Param("uid")),
|
|
117
|
+
__param(1, AuthUser),
|
|
118
|
+
__metadata("design:type", Function),
|
|
119
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
120
|
+
__metadata("design:returntype", Promise)
|
|
121
|
+
], BaseDeviceSyncStateRoute.prototype, "unblock", null);
|
|
90
122
|
//# sourceMappingURL=BaseDeviceSyncStateRoute.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BaseDeviceSyncStateRoute.js","sourceRoot":"","sources":["../../src/BaseDeviceSyncStateRoute.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,+EAA+E;AAC/E,mEAAmE;AACnE,mCAAmC;AACnC,+EAA+E;AAC/E,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAiB,SAAS,EAAgB,MAAM,iBAAiB,CAAC;AACrG,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAe,SAAS,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE/G,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,MAAM,EAAE,MAAM,EAAE,GAAG,gBAAgB,CAAC;AACpC,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,eAAe,CAAC;AAEvE
|
|
1
|
+
{"version":3,"file":"BaseDeviceSyncStateRoute.js","sourceRoot":"","sources":["../../src/BaseDeviceSyncStateRoute.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,+EAA+E;AAC/E,mEAAmE;AACnE,mCAAmC;AACnC,+EAA+E;AAC/E,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAiB,SAAS,EAAgB,MAAM,iBAAiB,CAAC;AACrG,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAe,SAAS,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE/G,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,MAAM,EAAE,MAAM,EAAE,GAAG,gBAAgB,CAAC;AACpC,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,eAAe,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,OAAgB,wBAAwB;IAA9C;QAIc,iBAAY,GAAa,CAAC,OAAO,CAAC,CAAC;IAwEjD,CAAC;IAjEW,KAAK,CAAC,sBAAsB;QAChC,IAAI,CAAC,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC5B,IAAI,CAAC,mBAAmB,GAAG,MAAM,IAAI,CAAC,cAAe,CAAC,WAAW,CAAC,SAAS,EAAE;gBACzE,IAAI,EAAE,IAAI,CAAC,oBAAoB,CAAC,IAAI;gBACpC,IAAI,EAAE,CAAC,IAAI,CAAC,oBAAoB,CAAC;aACpC,CAAC,CAAC;QACP,CAAC;QACD,OAAO,IAAI,CAAC,mBAAmB,CAAC;IACpC,CAAC;IAED;;;;;;OAMG;IAGU,AAAN,KAAK,CAAC,UAAU,CACL,GAAW,EAChB,GAAgB,EACf,IAAc;QAExB,IAAI,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC;YACxD,MAAM,IAAI,QAAQ,CAAC,SAAS,CAAC,uBAAuB,EAAE,GAAG,EAAE,gBAAgB,CAAC,uBAAuB,CAAC,CAAC;QACzG,CAAC;QAED,MAAM,IAAI,GAAiB,MAAM,IAAI,CAAC,sBAAsB,EAAE,CAAC;QAC/D,MAAM,eAAe,GAAkB,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACpF,IAAI,CAAC,eAAe,EAAE,CAAC;YACnB,MAAM,IAAI,QAAQ,CAAC,SAAS,CAAC,SAAS,EAAE,GAAG,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAC;QAC7E,CAAC;QAED,MAAM,WAAW,GAAY,GAAG,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAC;QAC5D,MAAM,sBAAsB,CAAC,eAAe,EAAE,IAAI,EAAE;YAChD,mBAAmB,EAAE,IAAI;YACzB,qBAAqB,EAAE,WAAW;YAClC,WAAW,EAAE,KAAK;YAClB,uFAAuF;YACvF,SAAS,EAAE,IAAI;SAClB,CAAC,CAAC;QACH,OAAO,eAAe,CAAC;IAC3B,CAAC;IAED;;;OAGG;IAGU,AAAN,KAAK,CAAC,OAAO,CAAe,GAAW,EAAY,IAAc;QACpE,IAAI,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC;YACxD,MAAM,IAAI,QAAQ,CAAC,SAAS,CAAC,uBAAuB,EAAE,GAAG,EAAE,gBAAgB,CAAC,uBAAuB,CAAC,CAAC;QACzG,CAAC;QAED,MAAM,IAAI,GAAiB,MAAM,IAAI,CAAC,sBAAsB,EAAE,CAAC;QAC/D,MAAM,eAAe,GAAkB,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACpF,IAAI,CAAC,eAAe,EAAE,CAAC;YACnB,MAAM,IAAI,QAAQ,CAAC,SAAS,CAAC,SAAS,EAAE,GAAG,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAC;QAC7E,CAAC;QAED,MAAM,sBAAsB,CAAC,eAAe,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;QACxE,OAAO,eAAe,CAAC;IAC3B,CAAC;CACJ;AAxEa;IADT,MAAM,CAAC,eAAe,EAAE,CAAC,OAAO,CAAC,CAAC;;8DACU;AA0BhC;IAFZ,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC;IACb,IAAI,CAAC,mBAAmB,CAAC;IAErB,WAAA,KAAK,CAAC,KAAK,CAAC,CAAA;IACZ,WAAA,OAAO,CAAA;IACP,WAAA,QAAQ,CAAA;;;;0DAqBZ;AAQY;IAFZ,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC;IACb,IAAI,CAAC,eAAe,CAAC;IACA,WAAA,KAAK,CAAC,KAAK,CAAC,CAAA;IAAe,WAAA,QAAQ,CAAA;;;;uDAaxD"}
|
package/dist/lib/BaseEasRoute.js
CHANGED
|
@@ -16,11 +16,11 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
16
16
|
///////////////////////////////////////////////////////////////////////////////
|
|
17
17
|
import { ApiError, ObjectDecorators } from "@rapidrest/core";
|
|
18
18
|
import { ApiErrorMessages, ApiErrors, RepoUtils, RouteDecorators, } from "@rapidrest/service-core";
|
|
19
|
-
import { WbxmlDecoder } from "./codec/WbxmlDecoder.js";
|
|
19
|
+
import { WbxmlDecodeError, WbxmlDecoder } from "./codec/WbxmlDecoder.js";
|
|
20
20
|
import { WbxmlEncoder } from "./codec/WbxmlEncoder.js";
|
|
21
21
|
import { persistDeviceSyncState } from "./EasSyncKeyUtils.js";
|
|
22
22
|
import { resolveCallerMailboxUid } from "@rapidmx/restapi";
|
|
23
|
-
const { Init, Logger } = ObjectDecorators;
|
|
23
|
+
const { Config, Init, Logger } = ObjectDecorators;
|
|
24
24
|
const { Auth, Options, Post, Request, Response, User: AuthUser } = RouteDecorators;
|
|
25
25
|
/** HTTP 449 ("Retry With") is not a standard HTTP status, but is the long-established Exchange ActiveSync
|
|
26
26
|
* convention a real client recognizes as "you must successfully complete `Provision` before this command will
|
|
@@ -38,9 +38,27 @@ const HTTP_STATUS_RETRY_WITH = 449;
|
|
|
38
38
|
* `ResolveRecipients`). No MS-ASCMD `Sync`/`FolderSync`/`Provision` schema element became newly mandatory
|
|
39
39
|
* between 14.1 and 16.1 outside `Oof`/IRM. Ascending, matching the order Microsoft's own spec lists them in. */
|
|
40
40
|
const MS_AS_PROTOCOL_VERSIONS = "14.0,14.1,16.0,16.1";
|
|
41
|
+
/** Default `mail:eas:max_request_bytes` - comfortably above a real device's largest request (a `SendMail` with
|
|
42
|
+
* attachments), far below the host-wide body limit. The WBXML decoder's own element limits bound memory further. */
|
|
43
|
+
const DEFAULT_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
44
|
+
/** Settings request elements a device may send before it is provisioned (and without a policy key): the first-run
|
|
45
|
+
* `UserInformation` lookup and `DeviceInformation` report. Anything else - `Oof` in particular, which writes the
|
|
46
|
+
* mailbox's automatic replies - needs a provisioned device. */
|
|
47
|
+
const SETTINGS_WITHOUT_PROVISIONING = new Set(["UserInformation", "DeviceInformation"]);
|
|
41
48
|
function firstQueryValue(value) {
|
|
42
49
|
return Array.isArray(value) ? value[0] : value;
|
|
43
50
|
}
|
|
51
|
+
/** An acceptable `DeviceId`: up to 128 visible ASCII characters other than parentheses and commas. [MS-ASHTTP] only
|
|
52
|
+
* allows alphanumerics; this stays more tolerant of real clients while keeping the value out of the query parser's
|
|
53
|
+
* `op(value)` and `in(a,b)` syntax - `DeviceId` goes straight into `find()` criteria for the device's own rows. */
|
|
54
|
+
const DEVICE_ID_PATTERN = /^[\x21-\x27\x2a\x2b\x2d-\x7e]{1,128}$/;
|
|
55
|
+
/** `DeviceId` values the query parser gives a meaning of its own even without an operator: `me` becomes the caller's
|
|
56
|
+
* uid (or a 403 without one) and `null` becomes an IS NULL match. Both are refused like any other malformed id. */
|
|
57
|
+
const RESERVED_DEVICE_IDS = new Set(["me", "null"]);
|
|
58
|
+
/** Whether `deviceId` is an acceptable `DeviceId` (`DEVICE_ID_PATTERN`, and not one of `RESERVED_DEVICE_IDS`). */
|
|
59
|
+
export function isValidDeviceId(deviceId) {
|
|
60
|
+
return deviceId !== undefined && DEVICE_ID_PATTERN.test(deviceId) && !RESERVED_DEVICE_IDS.has(deviceId);
|
|
61
|
+
}
|
|
44
62
|
/**
|
|
45
63
|
* Abstract base for the single fixed EAS endpoint (`POST /Microsoft-Server-ActiveSync` by MS-ASHTTP
|
|
46
64
|
* convention, though the concrete path is left to the consuming application to mount via `@Route(...)` — see
|
|
@@ -54,10 +72,13 @@ function firstQueryValue(value) {
|
|
|
54
72
|
* deliberately does not implement itself — see the architecture plan's "Auth" section for the full reasoning
|
|
55
73
|
* behind this choice over a per-request Basic Auth strategy.
|
|
56
74
|
*
|
|
57
|
-
* **Dispatch flow**: resolve the caller's own `Mailbox` (never
|
|
58
|
-
*
|
|
59
|
-
* `
|
|
60
|
-
*
|
|
75
|
+
* **Dispatch flow**: reject a body over `mail:eas:max_request_bytes` (413), resolve the caller's own `Mailbox` (never
|
|
76
|
+
* a client-supplied one — `resolveCallerMailboxUid`), find-or-create that (mailbox, device) pair's `DeviceSyncState`,
|
|
77
|
+
* refuse a `blocked` device (one that acknowledged a remote wipe) with 403 for anything but `Provision` (which
|
|
78
|
+
* answers Status 129 itself), decode the WBXML request body (if any; malformed or over the decoder's element limits
|
|
79
|
+
* -> 400), enforce the provisioning gate (provisioned *and* presenting the stored policy key, else 449 - only
|
|
80
|
+
* `Provision` and a `Settings` request limited to `UserInformation`/`DeviceInformation` are exempt), dispatch to the matching registered
|
|
81
|
+
* `EasCommandHandler`, record `lastSyncAt` (best-effort), and encode the handler's response back to WBXML.
|
|
61
82
|
*
|
|
62
83
|
* **Command handlers** are supplied via `commandHandlerClasses` (empty by default — this class alone is just
|
|
63
84
|
* the transport skeleton; concrete command support, e.g. `ProvisionCommand`/`FolderSyncCommand`, is added
|
|
@@ -84,6 +105,8 @@ export class BaseEasRoute {
|
|
|
84
105
|
* for a transport skeleton with no commands implemented yet, not a bug to work around. */
|
|
85
106
|
this.commandHandlerClasses = [];
|
|
86
107
|
this.handlers = new Map();
|
|
108
|
+
/** Largest request body `dispatch()` accepts (HTTP 413 beyond it). */
|
|
109
|
+
this.maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES;
|
|
87
110
|
}
|
|
88
111
|
async init() {
|
|
89
112
|
this.deviceSyncStateRepo = await this._objectFactory.newInstance(RepoUtils, {
|
|
@@ -124,40 +147,70 @@ export class BaseEasRoute {
|
|
|
124
147
|
const deviceId = firstQueryValue(req.query["DeviceId"]);
|
|
125
148
|
const deviceType = firstQueryValue(req.query["DeviceType"]) ?? "Unknown";
|
|
126
149
|
const policyKey = firstQueryValue(req.query["PolicyKey"]);
|
|
127
|
-
if (!cmd || !deviceId) {
|
|
150
|
+
if (!cmd || !isValidDeviceId(deviceId)) {
|
|
128
151
|
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
129
152
|
}
|
|
153
|
+
// service-core has no per-route body limit, so the host-wide `max_body_size` still bounds how much was
|
|
154
|
+
// read; this rejects anything beyond the (much lower) EAS limit before any decoding or database work.
|
|
155
|
+
const declaredLength = Number(firstQueryValue(req.headers["content-length"]));
|
|
156
|
+
if ((Number.isFinite(declaredLength) && declaredLength > this.maxRequestBytes) || (req.rawBody?.length ?? 0) > this.maxRequestBytes) {
|
|
157
|
+
res.status(413).send();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
130
160
|
const mailboxUid = await resolveCallerMailboxUid(this.mailboxRepo, user);
|
|
131
161
|
if (!mailboxUid) {
|
|
132
162
|
throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
|
|
133
163
|
}
|
|
134
164
|
const deviceSyncState = await this.findOrCreateDeviceSyncState(mailboxUid, deviceId, deviceType);
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!deviceSyncState.provisioned && cmd !== "Provision" && cmd !== "Settings") {
|
|
139
|
-
res.status(HTTP_STATUS_RETRY_WITH).send();
|
|
165
|
+
// A device that acknowledged a remote wipe stays locked out until an administrator unblocks it.
|
|
166
|
+
if (deviceSyncState.blocked && cmd !== "Provision") {
|
|
167
|
+
res.status(403).send();
|
|
140
168
|
return;
|
|
141
169
|
}
|
|
142
170
|
const handler = this.handlers.get(cmd);
|
|
171
|
+
// Settings is decoded ahead of the provisioning gate, which needs to see what it asks for; everything else is
|
|
172
|
+
// only decoded once the gate has passed.
|
|
173
|
+
let request = cmd === "Settings" && handler ? this.decodeRequest(req) : undefined;
|
|
174
|
+
// Every command except what must work on an unprovisioned device (Provision itself, and the first-run
|
|
175
|
+
// Settings lookups real clients make before provisioning completes) requires the device to be provisioned AND
|
|
176
|
+
// to present the policy key it acknowledged ([MS-ASPROV]: `X-MS-PolicyKey`, or the `PolicyKey` query value).
|
|
177
|
+
// A missing or stale key - e.g. one from before an admin-requested remote wipe, which clears the stored key -
|
|
178
|
+
// is sent back through Provision with the same 449 rather than being served.
|
|
179
|
+
if (!this.exemptFromProvisioning(cmd, request)) {
|
|
180
|
+
const presentedKey = firstQueryValue(req.headers["x-ms-policykey"]) ?? policyKey;
|
|
181
|
+
if (!deviceSyncState.provisioned || !deviceSyncState.policyKey || presentedKey !== deviceSyncState.policyKey) {
|
|
182
|
+
res.status(HTTP_STATUS_RETRY_WITH).send();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
143
186
|
if (!handler) {
|
|
144
187
|
res.status(501).send();
|
|
145
188
|
return;
|
|
146
189
|
}
|
|
147
|
-
|
|
190
|
+
if (cmd !== "Settings") {
|
|
191
|
+
request = this.decodeRequest(req);
|
|
192
|
+
}
|
|
148
193
|
const response = await handler.handle({
|
|
149
194
|
user,
|
|
150
195
|
mailboxUid,
|
|
151
196
|
deviceId,
|
|
152
197
|
deviceType,
|
|
153
|
-
policyKey,
|
|
198
|
+
policyKey: firstQueryValue(req.headers["x-ms-policykey"]) ?? policyKey,
|
|
154
199
|
deviceSyncState,
|
|
155
200
|
deviceSyncStateRepo: this.deviceSyncStateRepo,
|
|
156
201
|
query: req.query,
|
|
157
202
|
request,
|
|
158
203
|
req,
|
|
204
|
+
res,
|
|
159
205
|
});
|
|
160
|
-
|
|
206
|
+
// Bookkeeping only - the command already ran, so failing to record the timestamp must never turn a
|
|
207
|
+
// successful response into an error.
|
|
208
|
+
try {
|
|
209
|
+
await persistDeviceSyncState(deviceSyncState, this.deviceSyncStateRepo, { lastSyncAt: new Date() });
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
this.logger?.warn(`BaseEasRoute: failed to record lastSyncAt for device ${deviceId}: ${err?.message}`);
|
|
213
|
+
}
|
|
161
214
|
if (!response) {
|
|
162
215
|
res.status(200).send();
|
|
163
216
|
return;
|
|
@@ -168,6 +221,25 @@ export class BaseEasRoute {
|
|
|
168
221
|
.status(200)
|
|
169
222
|
.send(buffer);
|
|
170
223
|
}
|
|
224
|
+
/** Decodes the WBXML request body, if any (malformed or over the decoder's element limits -> 400). */
|
|
225
|
+
decodeRequest(req) {
|
|
226
|
+
try {
|
|
227
|
+
return req.rawBody && req.rawBody.length > 0 ? new WbxmlDecoder().decode(req.rawBody) : undefined;
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
if (err instanceof WbxmlDecodeError) {
|
|
231
|
+
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `Malformed WBXML request: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** `Provision`, and a `Settings` request whose every element is one `SETTINGS_WITHOUT_PROVISIONING` allows. */
|
|
237
|
+
exemptFromProvisioning(cmd, request) {
|
|
238
|
+
if (cmd === "Provision") {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return cmd === "Settings" && (request?.children ?? []).every((child) => SETTINGS_WITHOUT_PROVISIONING.has(child.tag));
|
|
242
|
+
}
|
|
171
243
|
async findOrCreateDeviceSyncState(mailboxUid, deviceId, deviceType) {
|
|
172
244
|
const existing = await this.deviceSyncStateRepo.find({ mailboxUid, deviceId }, { ignoreACL: true, limit: 1 });
|
|
173
245
|
if (existing[0]) {
|
|
@@ -183,6 +255,10 @@ export class BaseEasRoute {
|
|
|
183
255
|
return await this.deviceSyncStateRepo.create(instance, { ignoreACL: true });
|
|
184
256
|
}
|
|
185
257
|
}
|
|
258
|
+
__decorate([
|
|
259
|
+
Config("mail:eas:max_request_bytes", DEFAULT_MAX_REQUEST_BYTES),
|
|
260
|
+
__metadata("design:type", Number)
|
|
261
|
+
], BaseEasRoute.prototype, "maxRequestBytes", void 0);
|
|
186
262
|
__decorate([
|
|
187
263
|
Logger,
|
|
188
264
|
__metadata("design:type", Object)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BaseEasRoute.js","sourceRoot":"","sources":["../../src/BaseEasRoute.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,+EAA+E;AAC/E,mEAAmE;AACnE,mCAAmC;AACnC,+EAA+E;AAC/E,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAgB,MAAM,iBAAiB,CAAC;AAC3E,OAAO,EACH,gBAAgB,EAChB,SAAS,EAIT,SAAS,EACT,eAAe,GAClB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"BaseEasRoute.js","sourceRoot":"","sources":["../../src/BaseEasRoute.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isNonOwnerAccess, recordAuditLog } from "@rapidmx/restapi";
|
|
2
|
+
/**
|
|
3
|
+
* Audit entries for ActiveSync access to mail in a mailbox the caller doesn't own - an administrator reaching in
|
|
4
|
+
* through a trusted role, or a delegate working in a shared folder. It uses restapi's `recordAuditLog()` and
|
|
5
|
+
* `isNonOwnerAccess()`, as `BaseMessageRoute` does: REST records `MESSAGE_CONTENT_ACCESSED` for a non-owner content
|
|
6
|
+
* read and `MESSAGE_DELETE` for a delete.
|
|
7
|
+
*
|
|
8
|
+
* Built once per request. The caller's own mailbox (`ctx.mailboxUid`, resolved by `ownerUserUid`) is never audited
|
|
9
|
+
* and needs no lookup. Any other mailbox is loaded once. A mailbox that can't be loaded counts as non-owner, the
|
|
10
|
+
* defensive reading restapi's `content()` uses. Recording never throws: an audit failure is logged and the command
|
|
11
|
+
* goes on, like restapi's own best-effort persistence.
|
|
12
|
+
*
|
|
13
|
+
* Every entry's `details` carries `protocol: "ActiveSync"`, the command and the `DeviceId`.
|
|
14
|
+
*/
|
|
15
|
+
export class EasAuditLog {
|
|
16
|
+
constructor(deps, ctx, command) {
|
|
17
|
+
this.deps = deps;
|
|
18
|
+
this.ctx = ctx;
|
|
19
|
+
this.command = command;
|
|
20
|
+
this.nonOwner = new Map();
|
|
21
|
+
}
|
|
22
|
+
/** Whether reading `mailboxUid`'s content is a non-owner access for this request's caller. */
|
|
23
|
+
isNonOwner(mailboxUid) {
|
|
24
|
+
if (mailboxUid === this.ctx.mailboxUid) {
|
|
25
|
+
return Promise.resolve(false);
|
|
26
|
+
}
|
|
27
|
+
let pending = this.nonOwner.get(mailboxUid);
|
|
28
|
+
if (!pending) {
|
|
29
|
+
pending = this.deps.mailboxRepo
|
|
30
|
+
.findOne(mailboxUid, { ignoreACL: true })
|
|
31
|
+
.then((mailbox) => !mailbox || isNonOwnerAccess(mailbox, this.ctx.user))
|
|
32
|
+
.catch(() => true);
|
|
33
|
+
this.nonOwner.set(mailboxUid, pending);
|
|
34
|
+
}
|
|
35
|
+
return pending;
|
|
36
|
+
}
|
|
37
|
+
/** Records `entry` when its mailbox isn't the caller's own. */
|
|
38
|
+
async record(entry) {
|
|
39
|
+
try {
|
|
40
|
+
if (!(await this.isNonOwner(entry.mailboxUid))) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await recordAuditLog(this.deps.objectFactory, this.deps.auditLogClass, { config: this.deps.config, req: this.ctx.req, user: this.ctx.user, logger: this.deps.logger }, {
|
|
44
|
+
action: entry.action,
|
|
45
|
+
targetType: entry.targetType,
|
|
46
|
+
targetUid: entry.targetUid,
|
|
47
|
+
mailboxUid: entry.mailboxUid,
|
|
48
|
+
details: { protocol: "ActiveSync", command: this.command, deviceId: this.ctx.deviceId, ...entry.details },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
this.deps.logger?.warn(`${this.command}: failed to record audit entry ${entry.action} ${entry.targetType}:${entry.targetUid}: ${err?.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=EasAuditLog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EasAuditLog.js","sourceRoot":"","sources":["../../src/EasAuditLog.ts"],"names":[],"mappings":"AAMA,OAAO,EAAe,gBAAgB,EAAE,cAAc,EAAgB,MAAM,kBAAkB,CAAC;AAwB/F;;;;;;;;;;;;GAYG;AACH,MAAM,OAAO,WAAW;IAGpB,YACqB,IAA0B,EAC1B,GAAsB,EACtB,OAAe;QAFf,SAAI,GAAJ,IAAI,CAAsB;QAC1B,QAAG,GAAH,GAAG,CAAmB;QACtB,YAAO,GAAP,OAAO,CAAQ;QALnB,aAAQ,GAAkC,IAAI,GAAG,EAAE,CAAC;IAMlE,CAAC;IAEJ,8FAA8F;IACvF,UAAU,CAAC,UAAkB;QAChC,IAAI,UAAU,KAAK,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC;YACrC,OAAO,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAClC,CAAC;QACD,IAAI,OAAO,GAAiC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC1E,IAAI,CAAC,OAAO,EAAE,CAAC;YACX,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW;iBAC1B,OAAO,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;iBACxC,IAAI,CAAC,CAAC,OAA4B,EAAE,EAAE,CAAC,CAAC,OAAO,IAAI,gBAAgB,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;iBAC5F,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YACvB,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;QAC3C,CAAC;QACD,OAAO,OAAO,CAAC;IACnB,CAAC;IAED,+DAA+D;IACxD,KAAK,CAAC,MAAM,CAAC,KAAoB;QACpC,IAAI,CAAC;YACD,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC;gBAC7C,OAAO;YACX,CAAC;YACD,MAAM,cAAc,CAChB,IAAI,CAAC,IAAI,CAAC,aAAa,EACvB,IAAI,CAAC,IAAI,CAAC,aAAa,EACvB,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAC9F;gBACI,MAAM,EAAE,KAAK,CAAC,MAAM;gBACpB,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE;aAC5G,CACJ,CAAC;QACN,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,OAAO,kCAAkC,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,SAAS,KAAK,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC;QACpJ,CAAC;IACL,CAAC;CACJ"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import * as crypto from "crypto";
|
|
6
|
+
import { createClient } from "redis";
|
|
7
|
+
/** Deletes the lease key only while it still carries this holder's token, so an expired-and-retaken lease survives. */
|
|
8
|
+
const RELEASE_SCRIPT = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
|
9
|
+
/** Extends the lease key's expiry only while it still carries this holder's token. */
|
|
10
|
+
const RENEW_SCRIPT = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end';
|
|
11
|
+
/** Default `LeaseOptions.redisTimeoutMs`. */
|
|
12
|
+
const DEFAULT_REDIS_TIMEOUT_MS = 2000;
|
|
13
|
+
/** Reconnect attempts after which a lost Redis connection gives up (and a later lease creates a fresh client). */
|
|
14
|
+
const MAX_RECONNECT_ATTEMPTS = 3;
|
|
15
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
/** Sentinel a timed-out Redis call resolves with. */
|
|
17
|
+
const TIMED_OUT = Symbol("timed out");
|
|
18
|
+
/** Resolves with `promise`'s value, or `TIMED_OUT` after `ms` (a rejection still rejects). */
|
|
19
|
+
async function withTimeout(promise, ms) {
|
|
20
|
+
let timer;
|
|
21
|
+
try {
|
|
22
|
+
return await Promise.race([promise, new Promise((resolve) => (timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, ms))))]);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A short exclusive lease on one key - `SyncCommand` holds one per (mailbox, device, folder) for the duration of a
|
|
30
|
+
* collection's round, so two concurrent `Sync`s of the same collection (a client retrying while its first request is
|
|
31
|
+
* still running, or two server copies) can't both read the same state, apply their commands and overwrite each
|
|
32
|
+
* other's result.
|
|
33
|
+
*
|
|
34
|
+
* Always taken in-process first (a per-key promise chain), then - when a Redis URL is configured - as a `SET NX PX`
|
|
35
|
+
* key shared by every server copy, released with a token check and renewed (token-checked `PEXPIRE`) every
|
|
36
|
+
* `ttlMs / 3` while held, so a round that outlasts `ttlMs` doesn't lose it to another copy.
|
|
37
|
+
*
|
|
38
|
+
* **Redis being unreachable fails open** to the in-process lease alone rather than refusing - or hanging - every
|
|
39
|
+
* `Sync`: the client is created with `disableOfflineQueue` (a command fails at once while disconnected instead of
|
|
40
|
+
* waiting in a queue) and a bounded `reconnectStrategy` (so `connect()` rejects instead of retrying forever), and
|
|
41
|
+
* every connect/`SET` is additionally raced against `redisTimeoutMs`. The connect and the first `SET` get the whole
|
|
42
|
+
* `redisTimeoutMs` even when the in-process wait already used up `waitMs` - otherwise a healthy Redis would get no time
|
|
43
|
+
* at all and the lease would fail open for nothing; only the polling `SET`s after Redis has answered are cut short by
|
|
44
|
+
* the deadline, which then means another copy still holds the key (`undefined`). A `SET` that times out but lands later
|
|
45
|
+
* is released again straight away. A client whose reconnects gave up is replaced on the next lease.
|
|
46
|
+
*
|
|
47
|
+
* **Release never hangs**: the token-checked release is raced against `redisTimeoutMs` too (the key expires on its
|
|
48
|
+
* own), and the in-process lease is freed whatever happens to it.
|
|
49
|
+
*
|
|
50
|
+
* @author Jean-Philippe Steinmetz
|
|
51
|
+
*/
|
|
52
|
+
export class EasCollectionLease {
|
|
53
|
+
/** Forgets all process-wide state. Intended for tests. */
|
|
54
|
+
static resetSharedState() {
|
|
55
|
+
EasCollectionLease.local.clear();
|
|
56
|
+
EasCollectionLease.clients.clear();
|
|
57
|
+
}
|
|
58
|
+
static getClient(url, connectTimeoutMs) {
|
|
59
|
+
let pending = EasCollectionLease.clients.get(url);
|
|
60
|
+
if (!pending) {
|
|
61
|
+
const client = createClient({
|
|
62
|
+
url,
|
|
63
|
+
disableOfflineQueue: true,
|
|
64
|
+
socket: {
|
|
65
|
+
connectTimeout: connectTimeoutMs,
|
|
66
|
+
reconnectStrategy: (retries) => retries >= MAX_RECONNECT_ATTEMPTS ? new Error("Redis unreachable") : Math.min(100 * 2 ** retries, 1000),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
// An unhandled `error` event would crash the process.
|
|
70
|
+
client.on("error", () => undefined);
|
|
71
|
+
const connecting = client.connect().then(() => client);
|
|
72
|
+
connecting.catch(() => EasCollectionLease.forgetClient(url, connecting));
|
|
73
|
+
EasCollectionLease.clients.set(url, connecting);
|
|
74
|
+
pending = connecting;
|
|
75
|
+
}
|
|
76
|
+
return pending;
|
|
77
|
+
}
|
|
78
|
+
static forgetClient(url, pending) {
|
|
79
|
+
if (EasCollectionLease.clients.get(url) === pending) {
|
|
80
|
+
EasCollectionLease.clients.delete(url);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Waits for `key`'s in-process holder (if any) to release, up to `deadline`, then takes it over with `held` - the
|
|
84
|
+
* check and the take-over happen in the same tick, so two waiters can never both find the key free. `false` on
|
|
85
|
+
* timeout. */
|
|
86
|
+
static async takeLocal(key, held, deadline) {
|
|
87
|
+
for (;;) {
|
|
88
|
+
const holder = EasCollectionLease.local.get(key);
|
|
89
|
+
if (!holder) {
|
|
90
|
+
EasCollectionLease.local.set(key, held);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const remaining = deadline - Date.now();
|
|
94
|
+
if (remaining <= 0) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
let timer;
|
|
98
|
+
await Promise.race([holder, new Promise((resolve) => (timer = setTimeout(resolve, remaining)))]);
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Acquires the lease on `key`, waiting up to `options.waitMs` for a current holder. Resolves with the release
|
|
104
|
+
* function, or `undefined` when the lease couldn't be acquired in time.
|
|
105
|
+
*/
|
|
106
|
+
static async acquire(key, options) {
|
|
107
|
+
const deadline = Date.now() + options.waitMs;
|
|
108
|
+
let resolveLocal;
|
|
109
|
+
const held = new Promise((resolve) => (resolveLocal = resolve));
|
|
110
|
+
if (!(await EasCollectionLease.takeLocal(key, held, deadline))) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const releaseLocal = () => {
|
|
114
|
+
if (EasCollectionLease.local.get(key) === held) {
|
|
115
|
+
EasCollectionLease.local.delete(key);
|
|
116
|
+
}
|
|
117
|
+
resolveLocal();
|
|
118
|
+
};
|
|
119
|
+
const failOpen = async () => releaseLocal();
|
|
120
|
+
if (!options.redisUrl) {
|
|
121
|
+
return failOpen;
|
|
122
|
+
}
|
|
123
|
+
const url = options.redisUrl;
|
|
124
|
+
const redisTimeoutMs = options.redisTimeoutMs ?? DEFAULT_REDIS_TIMEOUT_MS;
|
|
125
|
+
// A polling SET never waits past the deadline for Redis; with no time left at all, it still gets the current tick.
|
|
126
|
+
const pollTimeout = () => Math.min(redisTimeoutMs, Math.max(0, deadline - Date.now()));
|
|
127
|
+
const redisKey = `eas:lease:${key}`;
|
|
128
|
+
const token = crypto.randomUUID();
|
|
129
|
+
const releaseRedis = async (client) => {
|
|
130
|
+
try {
|
|
131
|
+
await client.eval(RELEASE_SCRIPT, { keys: [redisKey], arguments: [token] });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// The key expires on its own.
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
let client;
|
|
138
|
+
try {
|
|
139
|
+
const pending = EasCollectionLease.getClient(url, redisTimeoutMs);
|
|
140
|
+
const connected = await withTimeout(pending, redisTimeoutMs);
|
|
141
|
+
if (connected === TIMED_OUT) {
|
|
142
|
+
return failOpen;
|
|
143
|
+
}
|
|
144
|
+
client = connected;
|
|
145
|
+
if (client.isOpen === false) {
|
|
146
|
+
// Its reconnects gave up: the next lease starts a fresh client.
|
|
147
|
+
EasCollectionLease.forgetClient(url, pending);
|
|
148
|
+
return failOpen;
|
|
149
|
+
}
|
|
150
|
+
let answered = false;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const setting = client.set(redisKey, token, { condition: "NX", expiration: { type: "PX", value: options.ttlMs } });
|
|
153
|
+
const reply = await withTimeout(setting, answered ? pollTimeout() : redisTimeoutMs);
|
|
154
|
+
if (reply === TIMED_OUT) {
|
|
155
|
+
// Give back the key should the SET still land.
|
|
156
|
+
setting.then((late) => (late === "OK" ? releaseRedis(client) : undefined)).catch(() => undefined);
|
|
157
|
+
if (answered && Date.now() >= deadline) {
|
|
158
|
+
// Redis is up and another copy holds the key - only the wait ran out.
|
|
159
|
+
releaseLocal();
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
return failOpen;
|
|
163
|
+
}
|
|
164
|
+
answered = true;
|
|
165
|
+
if (reply === "OK") {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (Date.now() >= deadline) {
|
|
169
|
+
releaseLocal();
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
await sleep(Math.min(options.pollMs ?? 100, Math.max(1, deadline - Date.now())));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Redis unreachable: fail open to the in-process lease.
|
|
177
|
+
return failOpen;
|
|
178
|
+
}
|
|
179
|
+
const renewal = setInterval(() => {
|
|
180
|
+
client.eval(RENEW_SCRIPT, { keys: [redisKey], arguments: [token, String(options.ttlMs)] }).catch(() => undefined);
|
|
181
|
+
}, Math.max(1, Math.floor(options.ttlMs / 3)));
|
|
182
|
+
renewal.unref?.();
|
|
183
|
+
return async () => {
|
|
184
|
+
clearInterval(renewal);
|
|
185
|
+
try {
|
|
186
|
+
// An unresponsive (but connected) Redis must not hold this collection on this server copy.
|
|
187
|
+
await withTimeout(releaseRedis(client), redisTimeoutMs);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
releaseLocal();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The in-process holder of each key: resolves once that holder releases. */
|
|
196
|
+
EasCollectionLease.local = new Map();
|
|
197
|
+
/** Shared command clients, keyed by Redis URL. */
|
|
198
|
+
EasCollectionLease.clients = new Map();
|
|
199
|
+
//# sourceMappingURL=EasCollectionLease.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EasCollectionLease.js","sourceRoot":"","sources":["../../src/EasCollectionLease.ts"],"names":[],"mappings":"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"}
|