@helyx/bot 0.2.1 → 0.3.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/CHANGELOG.md +46 -0
- package/README.md +7 -1
- package/dist/cloud-connector.d.ts +1 -1
- package/dist/cloud-connector.js +36 -9
- package/dist/cloud-management-module.d.ts +6 -0
- package/dist/cloud-management-module.js +69 -14
- package/dist/control-server.d.ts +44 -2
- package/dist/control-server.js +82 -1
- package/dist/index.js +63 -3
- package/dist/modules.js +47 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0229cb1: Add the versioned Helyx privacy-provider contract, strict write and nullable
|
|
8
|
+
read boundaries, guarded attribution migrations, bounded hosted and self-hosted
|
|
9
|
+
provider execution, and the Privacy Centre runtime configuration.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [0229cb1]
|
|
14
|
+
- @helyx/module-manifest@0.4.0
|
|
15
|
+
- @helyx/sdk@0.3.0
|
|
16
|
+
- @helyx/database@0.3.0
|
|
17
|
+
- @helyx/platform@0.2.0
|
|
18
|
+
- @helyx/config@0.3.0
|
|
19
|
+
- @helyx/core@0.1.5
|
|
20
|
+
- @helyx/framework@0.1.5
|
|
21
|
+
|
|
22
|
+
## 0.2.3
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- Updated dependencies
|
|
27
|
+
- @helyx/config@0.2.2
|
|
28
|
+
- @helyx/database@0.2.2
|
|
29
|
+
- @helyx/platform@0.1.6
|
|
30
|
+
|
|
31
|
+
## 0.2.2
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- b0f1932: Cache Discord application owners outside the interaction acknowledgement path
|
|
36
|
+
so Cloud Connect commands and modals respond reliably during Discord API
|
|
37
|
+
latency.
|
|
38
|
+
|
|
39
|
+
Treat an unreadable local Cloud Connect identity as an actionable recovery
|
|
40
|
+
state instead of retrying indefinitely. Owners can revoke the old dashboard
|
|
41
|
+
installation and enrol again with a new one-use code.
|
|
42
|
+
|
|
43
|
+
Run bounded cleanup for expired interaction sessions and rate-limit windows.
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [18265bc]
|
|
46
|
+
- @helyx/database@0.2.1
|
|
47
|
+
- @helyx/platform@0.1.5
|
|
48
|
+
|
|
3
49
|
## 0.2.1
|
|
4
50
|
|
|
5
51
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -16,6 +16,12 @@ Gateway intents are derived from installed event contributions. The bot always r
|
|
|
16
16
|
|
|
17
17
|
When `HELYX_BOT_CONTROL_SECRET` is configured, the bot also starts a private authenticated HTTP control server on `HELYX_BOT_CONTROL_PORT`. The dashboard API uses this service to obtain real Discord roles and channels and to apply validated module, configuration and permission changes. Only `/health` is unauthenticated; all `/v1` routes require the shared bearer secret. Keep this endpoint on a private service network and do not assign it a public domain.
|
|
18
18
|
|
|
19
|
-
Optional Helyx Cloud Connect is a separate outbound management path for self-hosted deployments. It is disabled unless `HELYX_CLOUD_CONNECT_ENABLED=true` and requires a separate local `HELYX_DATA_ENCRYPTION_KEY`. The owner-only `/helyx-cloud` command supports connection-code enrolment, status, credential rotation, disconnection and bounded support consent. The bot never sends its Discord token, database URL or connector private key to Helyx Cloud, and local Discord operation continues when Cloud is unavailable.
|
|
19
|
+
Optional Helyx Cloud Connect is a separate outbound management path for self-hosted deployments. It is disabled unless `HELYX_CLOUD_CONNECT_ENABLED=true` and requires a separate local `HELYX_DATA_ENCRYPTION_KEY`. Preserve that key across every restart and deployment. If it is lost or changed, revoke the old installation in the dashboard and use a new one-use code with `/helyx-cloud connect`; the connector stops retrying until that recovery is completed. The owner-only `/helyx-cloud` command supports connection-code enrolment, status, credential rotation, disconnection and bounded support consent. The bot never sends its Discord token, database URL or connector private key to Helyx Cloud, and local Discord operation continues when Cloud is unavailable.
|
|
20
|
+
|
|
21
|
+
Privacy providers ship in the same hosted and self-hosted module packages.
|
|
22
|
+
`HELYX_PRIVACY_ENABLED` defaults to `false`; enabling it requires the private
|
|
23
|
+
bot-control endpoint and a separate least-privilege PostgreSQL executor
|
|
24
|
+
credential. The bot refuses Privacy execution when that credential resolves to
|
|
25
|
+
the ordinary runtime database role.
|
|
20
26
|
|
|
21
27
|
For packaged operation use `helyx start`. `helyx migrate` applies core database migrations without Discord, and read-only `helyx doctor` verifies environment, PostgreSQL migration history, and configured package metadata.
|
|
@@ -4,7 +4,7 @@ import type { Client } from "discord.js";
|
|
|
4
4
|
import type { Logger } from "pino";
|
|
5
5
|
import type { ConfiguredModulePackage } from "./modules.js";
|
|
6
6
|
import type { BotControlHandler } from "./control-server.js";
|
|
7
|
-
type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "revoked" | "stopped";
|
|
7
|
+
type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "recovery_required" | "revoked" | "stopped";
|
|
8
8
|
export interface CloudConnectorStatus {
|
|
9
9
|
state: ConnectorState;
|
|
10
10
|
installationId?: string;
|
package/dist/cloud-connector.js
CHANGED
|
@@ -136,7 +136,8 @@ export class CloudConnectorController {
|
|
|
136
136
|
if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
|
|
137
137
|
throw new CloudConnectorUserError("Cloud Connect is disabled in this deployment");
|
|
138
138
|
const existing = await this.#repository.getIdentity();
|
|
139
|
-
|
|
139
|
+
const recoveringIdentity = existing && !existing.revokedAt && this.#state === "recovery_required";
|
|
140
|
+
if (existing && !existing.revokedAt && !recoveringIdentity)
|
|
140
141
|
throw new CloudConnectorUserError("This Helyx deployment is already connected");
|
|
141
142
|
if (!this.#dataKey)
|
|
142
143
|
throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
|
|
@@ -183,6 +184,9 @@ export class CloudConnectorController {
|
|
|
183
184
|
}
|
|
184
185
|
const exchange = exchangeResponseSchema.parse(payload);
|
|
185
186
|
const encrypted = encryptPrivateKey(keyPair.privateKey, this.#dataKey, exchange.installationId);
|
|
187
|
+
if (recoveringIdentity && !(await this.#repository.revoke())) {
|
|
188
|
+
throw new CloudConnectorUserError("The previous local Cloud Connect identity could not be reset");
|
|
189
|
+
}
|
|
186
190
|
this.#identity = await this.#repository.createIdentity({
|
|
187
191
|
installationId: exchange.installationId,
|
|
188
192
|
credentialId: exchange.credentialId,
|
|
@@ -196,7 +200,9 @@ export class CloudConnectorController {
|
|
|
196
200
|
});
|
|
197
201
|
this.#connectionBlocked = false;
|
|
198
202
|
await this.#repository.appendAudit({
|
|
199
|
-
action:
|
|
203
|
+
action: recoveringIdentity
|
|
204
|
+
? "installation.recovered"
|
|
205
|
+
: "installation.enrolled",
|
|
200
206
|
source,
|
|
201
207
|
outcome: "succeeded",
|
|
202
208
|
});
|
|
@@ -312,6 +318,16 @@ export class CloudConnectorController {
|
|
|
312
318
|
catch (error) {
|
|
313
319
|
if (this.#stopping)
|
|
314
320
|
break;
|
|
321
|
+
if (error instanceof CloudConnectorIdentityKeyError) {
|
|
322
|
+
this.#connectionBlocked = true;
|
|
323
|
+
this.#state = "recovery_required";
|
|
324
|
+
this.#lastErrorCode = "local_identity_key_mismatch";
|
|
325
|
+
this.input.logger.error({
|
|
326
|
+
event: "cloud.identity_recovery_required",
|
|
327
|
+
reason: this.#lastErrorCode,
|
|
328
|
+
}, "Cloud Connect cannot decrypt its local identity. Revoke the old installation in the dashboard, then use a new one-use code with /helyx-cloud connect");
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
315
331
|
this.#state = "offline";
|
|
316
332
|
this.#lastErrorCode = safeConnectorError(error);
|
|
317
333
|
this.input.logger.warn({
|
|
@@ -598,13 +614,24 @@ function encryptPrivateKey(privateKey, dataKey, installationId) {
|
|
|
598
614
|
return { ciphertext, iv, tag: cipher.getAuthTag() };
|
|
599
615
|
}
|
|
600
616
|
function decryptPrivateKey(encrypted, dataKey, installationId) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
617
|
+
try {
|
|
618
|
+
const decipher = createDecipheriv("aes-256-gcm", dataKey, encrypted.iv);
|
|
619
|
+
decipher.setAAD(Buffer.from(`helyx-cloud-connector:${installationId}`, "utf8"));
|
|
620
|
+
decipher.setAuthTag(encrypted.tag);
|
|
621
|
+
return Buffer.concat([
|
|
622
|
+
decipher.update(encrypted.ciphertext),
|
|
623
|
+
decipher.final(),
|
|
624
|
+
]).toString("utf8");
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
throw new CloudConnectorIdentityKeyError();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
class CloudConnectorIdentityKeyError extends Error {
|
|
631
|
+
constructor() {
|
|
632
|
+
super("The local Cloud Connect identity cannot be decrypted");
|
|
633
|
+
this.name = "CloudConnectorIdentityKeyError";
|
|
634
|
+
}
|
|
608
635
|
}
|
|
609
636
|
function connectedMessage(input) {
|
|
610
637
|
const now = new Date();
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type ModuleDefinition } from "@helyx/sdk";
|
|
2
2
|
import type { Client } from "discord.js";
|
|
3
3
|
import type { CloudConnectorController } from "./cloud-connector.js";
|
|
4
|
+
export declare class DeploymentOwnerAuthorizer {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(client: Client, configuredOwnerUserIds: readonly string[]);
|
|
7
|
+
isOwner(userId: string): boolean;
|
|
8
|
+
refresh(): Promise<void>;
|
|
9
|
+
}
|
|
4
10
|
export declare function createCloudManagementModule(input: {
|
|
5
11
|
connector: CloudConnectorController;
|
|
6
12
|
client: Client;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineModule, } from "@helyx/sdk";
|
|
2
|
+
import { Events } from "discord.js";
|
|
2
3
|
import { CloudConnectorUserError, } from "./cloud-connector.js";
|
|
3
4
|
const CONNECT_MODAL_ID = "helyx.cloud.connect";
|
|
4
5
|
const CONNECT_CODE_FIELD_ID = "helyx.cloud.connect.code";
|
|
@@ -13,9 +14,41 @@ const DIAGNOSTICS_ENABLED_FIELD_ID = "helyx.cloud.support.diagnostics";
|
|
|
13
14
|
const VIOLET = 0x7c3aed;
|
|
14
15
|
const SUCCESS = 0x16a34a;
|
|
15
16
|
const DANGER = 0xdc2626;
|
|
17
|
+
const OWNER_REFRESH_INTERVAL_MS = 15 * 60 * 1_000;
|
|
18
|
+
export class DeploymentOwnerAuthorizer {
|
|
19
|
+
#client;
|
|
20
|
+
#configuredOwnerUserIds;
|
|
21
|
+
#applicationOwnerUserIds = new Set();
|
|
22
|
+
constructor(client, configuredOwnerUserIds) {
|
|
23
|
+
this.#client = client;
|
|
24
|
+
this.#configuredOwnerUserIds = new Set(configuredOwnerUserIds);
|
|
25
|
+
}
|
|
26
|
+
isOwner(userId) {
|
|
27
|
+
return (this.#configuredOwnerUserIds.has(userId) ||
|
|
28
|
+
this.#applicationOwnerUserIds.has(userId));
|
|
29
|
+
}
|
|
30
|
+
async refresh() {
|
|
31
|
+
const application = this.#client.application;
|
|
32
|
+
if (!application)
|
|
33
|
+
return;
|
|
34
|
+
this.#replaceApplicationOwners(application.owner);
|
|
35
|
+
const refreshed = await application.fetch();
|
|
36
|
+
this.#replaceApplicationOwners(refreshed.owner);
|
|
37
|
+
}
|
|
38
|
+
#replaceApplicationOwners(owner) {
|
|
39
|
+
this.#applicationOwnerUserIds = !owner
|
|
40
|
+
? new Set()
|
|
41
|
+
: "members" in owner
|
|
42
|
+
? new Set(owner.members.keys())
|
|
43
|
+
: new Set([owner.id]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
16
46
|
export function createCloudManagementModule(input) {
|
|
47
|
+
const ownerAuthorizer = new DeploymentOwnerAuthorizer(input.client, input.configuredOwnerUserIds);
|
|
48
|
+
let ownerRefreshInterval;
|
|
49
|
+
let onClientReady;
|
|
17
50
|
const requireOwner = async (context) => {
|
|
18
|
-
if (
|
|
51
|
+
if (ownerAuthorizer.isOwner(context.userId))
|
|
19
52
|
return true;
|
|
20
53
|
await context.reply({
|
|
21
54
|
ephemeral: true,
|
|
@@ -411,21 +444,37 @@ export function createCloudManagementModule(input) {
|
|
|
411
444
|
},
|
|
412
445
|
],
|
|
413
446
|
},
|
|
414
|
-
|
|
415
|
-
|
|
447
|
+
start(context) {
|
|
448
|
+
const refreshOwners = () => {
|
|
449
|
+
void ownerAuthorizer.refresh().catch((error) => {
|
|
450
|
+
context.logger.warn({
|
|
451
|
+
event: "cloud.owner_cache_refresh_failed",
|
|
452
|
+
error: safeError(error),
|
|
453
|
+
}, "Cloud Connect deployment-owner cache refresh failed");
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
onClientReady = () => {
|
|
457
|
+
refreshOwners();
|
|
458
|
+
ownerRefreshInterval ??= setInterval(refreshOwners, OWNER_REFRESH_INTERVAL_MS);
|
|
459
|
+
ownerRefreshInterval.unref();
|
|
460
|
+
};
|
|
461
|
+
if (input.client.isReady())
|
|
462
|
+
onClientReady();
|
|
463
|
+
else
|
|
464
|
+
input.client.once(Events.ClientReady, onClientReady);
|
|
465
|
+
return Promise.resolve();
|
|
466
|
+
},
|
|
467
|
+
stop() {
|
|
468
|
+
if (onClientReady)
|
|
469
|
+
input.client.off(Events.ClientReady, onClientReady);
|
|
470
|
+
if (ownerRefreshInterval)
|
|
471
|
+
clearInterval(ownerRefreshInterval);
|
|
472
|
+
onClientReady = undefined;
|
|
473
|
+
ownerRefreshInterval = undefined;
|
|
474
|
+
return Promise.resolve();
|
|
475
|
+
},
|
|
416
476
|
});
|
|
417
477
|
}
|
|
418
|
-
async function isDeploymentOwner(client, userId, configuredOwnerUserIds) {
|
|
419
|
-
if (configuredOwnerUserIds.includes(userId))
|
|
420
|
-
return true;
|
|
421
|
-
const application = await client.application?.fetch();
|
|
422
|
-
const owner = application?.owner;
|
|
423
|
-
if (!owner)
|
|
424
|
-
return false;
|
|
425
|
-
if ("members" in owner)
|
|
426
|
-
return owner.members.has(userId);
|
|
427
|
-
return owner.id === userId;
|
|
428
|
-
}
|
|
429
478
|
function statusResponse(status) {
|
|
430
479
|
const labels = {
|
|
431
480
|
disabled: "Disabled by deployment configuration",
|
|
@@ -433,6 +482,7 @@ function statusResponse(status) {
|
|
|
433
482
|
connecting: "Connecting securely",
|
|
434
483
|
online: "Connected",
|
|
435
484
|
offline: "Temporarily offline; local operation continues",
|
|
485
|
+
recovery_required: "Local identity recovery required; revoke the old dashboard installation, then connect with a new code",
|
|
436
486
|
revoked: "Disconnected and revoked",
|
|
437
487
|
stopped: "Stopped",
|
|
438
488
|
};
|
|
@@ -488,4 +538,9 @@ function restrictedPolicy() {
|
|
|
488
538
|
deniedUserIds: [],
|
|
489
539
|
};
|
|
490
540
|
}
|
|
541
|
+
function safeError(error) {
|
|
542
|
+
return error instanceof Error
|
|
543
|
+
? { name: error.name, message: error.message }
|
|
544
|
+
: { name: "Error", message: String(error) };
|
|
545
|
+
}
|
|
491
546
|
//# sourceMappingURL=cloud-management-module.js.map
|
package/dist/control-server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DatabasePool } from "@helyx/database";
|
|
2
|
-
import { type DashboardActionResult, type MessageResponse, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import { type DashboardActionResult, type MessageResponse, type ModuleDefinition, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
|
|
3
3
|
import { type Client } from "discord.js";
|
|
4
4
|
import { type FastifyInstance } from "fastify";
|
|
5
5
|
interface DashboardPermissionPolicy {
|
|
@@ -139,12 +139,54 @@ export interface BotControlHandler {
|
|
|
139
139
|
actorUserId: string;
|
|
140
140
|
correlationId: string;
|
|
141
141
|
}): Promise<DashboardActionResult>;
|
|
142
|
+
listPrivacyProviders(): Promise<readonly {
|
|
143
|
+
id: string;
|
|
144
|
+
version: number;
|
|
145
|
+
containsPersonalData: boolean;
|
|
146
|
+
}[]>;
|
|
147
|
+
capturePrivacy(input: {
|
|
148
|
+
providerId: string;
|
|
149
|
+
providerVersion: number;
|
|
150
|
+
requestId: string;
|
|
151
|
+
subjectUserId: string;
|
|
152
|
+
cutoffAt: Date;
|
|
153
|
+
cursor?: string;
|
|
154
|
+
}): Promise<PrivacyCapturePage>;
|
|
155
|
+
erasePrivacy(input: {
|
|
156
|
+
providerId: string;
|
|
157
|
+
providerVersion: number;
|
|
158
|
+
requestId: string;
|
|
159
|
+
subjectUserId: string;
|
|
160
|
+
executeThrough: Date;
|
|
161
|
+
cursor?: string;
|
|
162
|
+
}): Promise<PrivacyErasePage>;
|
|
142
163
|
}
|
|
143
164
|
export declare class BotControlService implements BotControlHandler {
|
|
144
165
|
#private;
|
|
145
166
|
private readonly database;
|
|
146
167
|
private readonly resources;
|
|
147
|
-
constructor(database: DatabasePool, modules: readonly ModuleDefinition[], resources: BotControlGuildResourceDirectory, services: ServiceAccess);
|
|
168
|
+
constructor(database: DatabasePool, modules: readonly ModuleDefinition[], resources: BotControlGuildResourceDirectory, services: ServiceAccess, privacyExecutor?: PrivacyProviderExecutor);
|
|
169
|
+
listPrivacyProviders(): Promise<readonly {
|
|
170
|
+
id: string;
|
|
171
|
+
version: number;
|
|
172
|
+
containsPersonalData: boolean;
|
|
173
|
+
}[]>;
|
|
174
|
+
capturePrivacy(input: {
|
|
175
|
+
providerId: string;
|
|
176
|
+
providerVersion: number;
|
|
177
|
+
requestId: string;
|
|
178
|
+
subjectUserId: string;
|
|
179
|
+
cutoffAt: Date;
|
|
180
|
+
cursor?: string;
|
|
181
|
+
}): Promise<PrivacyCapturePage>;
|
|
182
|
+
erasePrivacy(input: {
|
|
183
|
+
providerId: string;
|
|
184
|
+
providerVersion: number;
|
|
185
|
+
requestId: string;
|
|
186
|
+
subjectUserId: string;
|
|
187
|
+
executeThrough: Date;
|
|
188
|
+
cursor?: string;
|
|
189
|
+
}): Promise<PrivacyErasePage>;
|
|
148
190
|
getGuildState(guildId: string): Promise<BotControlState>;
|
|
149
191
|
setModuleStatus(input: {
|
|
150
192
|
guildId: string;
|
package/dist/control-server.js
CHANGED
|
@@ -96,7 +96,8 @@ export class BotControlService {
|
|
|
96
96
|
#installations;
|
|
97
97
|
#permissions;
|
|
98
98
|
#services;
|
|
99
|
-
|
|
99
|
+
#privacyProviders;
|
|
100
|
+
constructor(database, modules, resources, services, privacyExecutor) {
|
|
100
101
|
this.database = database;
|
|
101
102
|
this.resources = resources;
|
|
102
103
|
this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
@@ -107,6 +108,41 @@ export class BotControlService {
|
|
|
107
108
|
this.#installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
108
109
|
this.#permissions = services.get(HELYX_SERVICE_NAMES.permissions);
|
|
109
110
|
this.#services = services;
|
|
111
|
+
this.#privacyProviders = new Map(privacyExecutor
|
|
112
|
+
? modules
|
|
113
|
+
.filter((module) => module.privacy !== undefined)
|
|
114
|
+
.map((module) => {
|
|
115
|
+
const provider = module.privacy.create(privacyExecutor);
|
|
116
|
+
return [provider.id, provider];
|
|
117
|
+
})
|
|
118
|
+
: []);
|
|
119
|
+
}
|
|
120
|
+
listPrivacyProviders() {
|
|
121
|
+
return Promise.resolve([...this.#privacyProviders.values()]
|
|
122
|
+
.map((provider) => ({
|
|
123
|
+
id: provider.id,
|
|
124
|
+
version: provider.version,
|
|
125
|
+
containsPersonalData: provider.describe().containsPersonalData,
|
|
126
|
+
}))
|
|
127
|
+
.sort((left, right) => left.id.localeCompare(right.id)));
|
|
128
|
+
}
|
|
129
|
+
capturePrivacy(input) {
|
|
130
|
+
const provider = this.#privacyProvider(input.providerId, input.providerVersion);
|
|
131
|
+
return provider.captureAccess({
|
|
132
|
+
requestId: input.requestId,
|
|
133
|
+
discordUserId: input.subjectUserId,
|
|
134
|
+
cutoffAt: input.cutoffAt,
|
|
135
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
erasePrivacy(input) {
|
|
139
|
+
const provider = this.#privacyProvider(input.providerId, input.providerVersion);
|
|
140
|
+
return provider.erase({
|
|
141
|
+
requestId: input.requestId,
|
|
142
|
+
discordUserId: input.subjectUserId,
|
|
143
|
+
executeThrough: input.executeThrough,
|
|
144
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
145
|
+
});
|
|
110
146
|
}
|
|
111
147
|
async getGuildState(guildId) {
|
|
112
148
|
const [resources, installedModules, storedLoggingPolicies] = await Promise.all([
|
|
@@ -367,6 +403,14 @@ export class BotControlService {
|
|
|
367
403
|
throw new BotControlNotFoundError("Module is not dashboard configurable");
|
|
368
404
|
return module;
|
|
369
405
|
}
|
|
406
|
+
#privacyProvider(providerId, providerVersion) {
|
|
407
|
+
const provider = this.#privacyProviders.get(providerId);
|
|
408
|
+
if (!provider)
|
|
409
|
+
throw new BotControlNotFoundError("Privacy provider is not available");
|
|
410
|
+
if (provider.version !== providerVersion)
|
|
411
|
+
throw new BotControlValidationError("Privacy provider version does not match");
|
|
412
|
+
return provider;
|
|
413
|
+
}
|
|
370
414
|
async #getPermissionState(guildId, module) {
|
|
371
415
|
const commands = dashboardCommands(module);
|
|
372
416
|
const modulePolicy = await this.#permissions.getPolicy(guildId, module.manifest.id);
|
|
@@ -462,6 +506,43 @@ export async function createBotControlServer(input) {
|
|
|
462
506
|
});
|
|
463
507
|
server.get("/health", () => ({ status: "ok" }));
|
|
464
508
|
server.get("/v1/health", () => ({ status: "ok" }));
|
|
509
|
+
server.get("/v1/privacy/providers", () => input.service.listPrivacyProviders());
|
|
510
|
+
server.post("/v1/privacy/capture", async (request) => {
|
|
511
|
+
const body = z
|
|
512
|
+
.object({
|
|
513
|
+
providerId: moduleId,
|
|
514
|
+
providerVersion: z.number().int().positive(),
|
|
515
|
+
requestId: z.uuid(),
|
|
516
|
+
subjectUserId: snowflake,
|
|
517
|
+
cutoffAt: z.coerce.date(),
|
|
518
|
+
cursor: z.string().min(1).max(2_048).optional(),
|
|
519
|
+
})
|
|
520
|
+
.strict()
|
|
521
|
+
.parse(request.body);
|
|
522
|
+
const { cursor, ...value } = body;
|
|
523
|
+
return input.service.capturePrivacy({
|
|
524
|
+
...value,
|
|
525
|
+
...(cursor ? { cursor } : {}),
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
server.post("/v1/privacy/erase", async (request) => {
|
|
529
|
+
const body = z
|
|
530
|
+
.object({
|
|
531
|
+
providerId: moduleId,
|
|
532
|
+
providerVersion: z.number().int().positive(),
|
|
533
|
+
requestId: z.uuid(),
|
|
534
|
+
subjectUserId: snowflake,
|
|
535
|
+
executeThrough: z.coerce.date(),
|
|
536
|
+
cursor: z.string().min(1).max(2_048).optional(),
|
|
537
|
+
})
|
|
538
|
+
.strict()
|
|
539
|
+
.parse(request.body);
|
|
540
|
+
const { cursor, ...value } = body;
|
|
541
|
+
return input.service.erasePrivacy({
|
|
542
|
+
...value,
|
|
543
|
+
...(cursor ? { cursor } : {}),
|
|
544
|
+
});
|
|
545
|
+
});
|
|
465
546
|
server.get("/v1/guilds/:guildId/state", async (request) => {
|
|
466
547
|
const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
|
|
467
548
|
return input.service.getGuildState(guildId);
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { parseBotEnvironment } from "@helyx/config";
|
|
2
2
|
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
3
|
-
import { checkDatabaseConnection, createDatabasePool, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
|
|
3
|
+
import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
|
|
4
4
|
import { createRuntime } from "@helyx/framework";
|
|
5
|
-
import { createPlatformServices } from "@helyx/platform";
|
|
5
|
+
import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
|
|
6
6
|
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
7
7
|
import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
|
|
8
8
|
import pino from "pino";
|
|
@@ -24,6 +24,7 @@ export { loadConfiguredModulePackages } from "./modules.js";
|
|
|
24
24
|
export { resolveModulePackageSpecifiers } from "./module-configuration.js";
|
|
25
25
|
export * from "./control-server.js";
|
|
26
26
|
const bootstrapLogger = pino({ base: { component: "bot" } });
|
|
27
|
+
const RETENTION_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
|
|
27
28
|
export async function startBot(processEnvironment = process.env) {
|
|
28
29
|
const environment = parseBotEnvironment(processEnvironment);
|
|
29
30
|
bootstrapLogger.level = environment.LOG_LEVEL;
|
|
@@ -32,21 +33,61 @@ export async function startBot(processEnvironment = process.env) {
|
|
|
32
33
|
sslMode: environment.DATABASE_SSL,
|
|
33
34
|
applicationName: "helyx-bot",
|
|
34
35
|
});
|
|
36
|
+
const privacyDatabase = environment.HELYX_PRIVACY_ENABLED &&
|
|
37
|
+
environment.HELYX_PRIVACY_EXECUTOR_DATABASE_URL &&
|
|
38
|
+
environment.HELYX_PRIVACY_EXECUTOR_DATABASE_SSL
|
|
39
|
+
? createDatabasePool({
|
|
40
|
+
connectionString: environment.HELYX_PRIVACY_EXECUTOR_DATABASE_URL,
|
|
41
|
+
sslMode: environment.HELYX_PRIVACY_EXECUTOR_DATABASE_SSL,
|
|
42
|
+
applicationName: "helyx-bot-privacy-executor",
|
|
43
|
+
maxConnections: 2,
|
|
44
|
+
})
|
|
45
|
+
: undefined;
|
|
35
46
|
let databaseClosed = false;
|
|
47
|
+
let retentionCleanupTimer;
|
|
36
48
|
async function closeDatabase() {
|
|
37
49
|
if (databaseClosed)
|
|
38
50
|
return;
|
|
39
51
|
databaseClosed = true;
|
|
52
|
+
if (privacyDatabase)
|
|
53
|
+
await privacyDatabase.end();
|
|
40
54
|
await database.end();
|
|
41
55
|
}
|
|
42
56
|
try {
|
|
43
57
|
await checkDatabaseConnection(database);
|
|
58
|
+
if (privacyDatabase) {
|
|
59
|
+
await checkDatabaseConnection(privacyDatabase);
|
|
60
|
+
await assertDistinctDatabaseRoles(database, privacyDatabase);
|
|
61
|
+
}
|
|
44
62
|
const migrationResult = await runCoreMigrations(database);
|
|
45
63
|
bootstrapLogger.info({
|
|
46
64
|
event: "database.ready",
|
|
47
65
|
appliedMigrations: migrationResult.applied,
|
|
48
66
|
verifiedMigrationCount: migrationResult.verified.length,
|
|
49
67
|
}, "Core database ready");
|
|
68
|
+
const cleanupExpiredCoreRecords = async () => {
|
|
69
|
+
const [sessions, rateLimits] = await Promise.all([
|
|
70
|
+
new InteractionSessionRepository(database).deleteExpired(),
|
|
71
|
+
new RateLimitRepository(database).deleteExpired(),
|
|
72
|
+
]);
|
|
73
|
+
if (sessions > 0 || rateLimits > 0) {
|
|
74
|
+
bootstrapLogger.info({
|
|
75
|
+
event: "database.retention_cleaned",
|
|
76
|
+
interactionSessions: sessions,
|
|
77
|
+
rateLimitWindows: rateLimits,
|
|
78
|
+
}, "Expired core records removed");
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await cleanupExpiredCoreRecords().catch((error) => {
|
|
82
|
+
bootstrapLogger.warn({ event: "database.retention_cleanup_failed", error: safeError(error) }, "Expired core record cleanup failed");
|
|
83
|
+
});
|
|
84
|
+
retentionCleanupTimer = setInterval(() => void cleanupExpiredCoreRecords().catch((error) => {
|
|
85
|
+
bootstrapLogger.warn({
|
|
86
|
+
event: "database.retention_cleanup_failed",
|
|
87
|
+
error: safeError(error),
|
|
88
|
+
}, "Expired core record cleanup failed");
|
|
89
|
+
}), RETENTION_CLEANUP_INTERVAL_MS);
|
|
90
|
+
retentionCleanupTimer.unref();
|
|
50
91
|
const moduleSpecifiers = await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH);
|
|
51
92
|
const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
|
|
52
93
|
const moduleMigrationResults = await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
|
|
@@ -115,7 +156,13 @@ export async function startBot(processEnvironment = process.env) {
|
|
|
115
156
|
prepare: prepareGuilds,
|
|
116
157
|
}, bootstrapLogger, serviceAccess);
|
|
117
158
|
installModuleEvents(client, modules, bootstrapLogger, serviceAccess);
|
|
118
|
-
const controlService = new BotControlService(database, modules, guildResources, serviceAccess
|
|
159
|
+
const controlService = new BotControlService(database, modules, guildResources, serviceAccess, privacyDatabase
|
|
160
|
+
? new PostgresPrivacyProviderExecutor({
|
|
161
|
+
readPool: database,
|
|
162
|
+
executorPool: privacyDatabase,
|
|
163
|
+
modules: featureModules,
|
|
164
|
+
})
|
|
165
|
+
: undefined);
|
|
119
166
|
cloudConnector.setControlHandler(controlService);
|
|
120
167
|
let controlServer;
|
|
121
168
|
let shuttingDown = false;
|
|
@@ -125,6 +172,8 @@ export async function startBot(processEnvironment = process.env) {
|
|
|
125
172
|
shuttingDown = true;
|
|
126
173
|
process.off("SIGINT", onSigint);
|
|
127
174
|
process.off("SIGTERM", onSigterm);
|
|
175
|
+
if (retentionCleanupTimer)
|
|
176
|
+
clearInterval(retentionCleanupTimer);
|
|
128
177
|
bootstrapLogger.info({ event: "bot.shutdown", reason }, "Bot shutdown requested");
|
|
129
178
|
await cloudConnector.stop().catch((error) => {
|
|
130
179
|
process.exitCode = 1;
|
|
@@ -192,12 +241,23 @@ export async function startBot(processEnvironment = process.env) {
|
|
|
192
241
|
return { shutdown };
|
|
193
242
|
}
|
|
194
243
|
catch (error) {
|
|
244
|
+
if (retentionCleanupTimer)
|
|
245
|
+
clearInterval(retentionCleanupTimer);
|
|
195
246
|
await closeDatabase().catch((closeError) => {
|
|
196
247
|
bootstrapLogger.error({ event: "database.close_failed", error: safeError(closeError) }, "Database pool cleanup failed");
|
|
197
248
|
});
|
|
198
249
|
throw error;
|
|
199
250
|
}
|
|
200
251
|
}
|
|
252
|
+
async function assertDistinctDatabaseRoles(runtime, privacy) {
|
|
253
|
+
const [runtimeIdentity, privacyIdentity] = await Promise.all([
|
|
254
|
+
runtime.query("SELECT current_user::text AS current_user"),
|
|
255
|
+
privacy.query("SELECT current_user::text AS current_user"),
|
|
256
|
+
]);
|
|
257
|
+
if (runtimeIdentity.rows[0]?.current_user ===
|
|
258
|
+
privacyIdentity.rows[0]?.current_user)
|
|
259
|
+
throw new Error("Privacy executor must use a distinct least-privilege database role");
|
|
260
|
+
}
|
|
201
261
|
function readBotVersion() {
|
|
202
262
|
const metadata = createRequire(import.meta.url)("../package.json");
|
|
203
263
|
if (typeof metadata !== "object" ||
|
package/dist/modules.js
CHANGED
|
@@ -66,6 +66,53 @@ export function validateContributionParity(definition) {
|
|
|
66
66
|
assertSameContributionIds(definition.manifest.contributions.events, executableEvents, "events");
|
|
67
67
|
assertSameContributionIds(definition.manifest.contributions.logs ?? [], executableLogs, "logs");
|
|
68
68
|
assertSameContributionIds(definition.manifest.dashboard?.actions?.map(({ id }) => id) ?? [], executableDashboardActions, "dashboard actions");
|
|
69
|
+
validatePrivacyParity(definition);
|
|
70
|
+
}
|
|
71
|
+
function validatePrivacyParity(definition) {
|
|
72
|
+
if (definition.manifest.manifestVersion === "0.1") {
|
|
73
|
+
if (definition.privacy !== undefined) {
|
|
74
|
+
throw new Error("Module executable privacy provider requires manifest version 0.2");
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (definition.privacy === undefined) {
|
|
79
|
+
throw new Error("Module manifest privacy declaration has no executable provider");
|
|
80
|
+
}
|
|
81
|
+
if (definition.privacy.providerVersion !==
|
|
82
|
+
definition.manifest.privacy.providerVersion) {
|
|
83
|
+
throw new Error("Module manifest privacy provider version does not match executable provider");
|
|
84
|
+
}
|
|
85
|
+
for (const collectionDeclaration of definition.manifest.privacy.collections) {
|
|
86
|
+
const collection = definition.records?.[collectionDeclaration.name];
|
|
87
|
+
if (collection === undefined) {
|
|
88
|
+
throw new Error(`Module privacy collection is not declared as a record collection: ${collectionDeclaration.name}`);
|
|
89
|
+
}
|
|
90
|
+
const privacyFields = [
|
|
91
|
+
collectionDeclaration.createdAtField,
|
|
92
|
+
...collectionDeclaration.subjectFields.map(({ field }) => field),
|
|
93
|
+
...(collectionDeclaration.contentFields ?? []).map(({ field }) => field),
|
|
94
|
+
];
|
|
95
|
+
for (const field of privacyFields) {
|
|
96
|
+
if (collection.fields[field] === undefined) {
|
|
97
|
+
throw new Error(`Module privacy field is not declared in record collection ${collectionDeclaration.name}: ${field}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const unavailable = () => {
|
|
102
|
+
throw new Error("Privacy provider executor is unavailable during loading");
|
|
103
|
+
};
|
|
104
|
+
const executor = {
|
|
105
|
+
captureAccess: unavailable,
|
|
106
|
+
erase: unavailable,
|
|
107
|
+
assessCorrection: unavailable,
|
|
108
|
+
};
|
|
109
|
+
const provider = definition.privacy.create(executor);
|
|
110
|
+
if (provider.id !== definition.manifest.id ||
|
|
111
|
+
provider.version !== definition.manifest.privacy.providerVersion ||
|
|
112
|
+
JSON.stringify(provider.describe()) !==
|
|
113
|
+
JSON.stringify(definition.manifest.privacy)) {
|
|
114
|
+
throw new Error("Module manifest privacy declaration does not match executable provider");
|
|
115
|
+
}
|
|
69
116
|
}
|
|
70
117
|
function assertSameContributionIds(declared, executable, label) {
|
|
71
118
|
if (new Set(executable).size !== executable.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helyx/bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Production Discord runtime for the modular Helyx platform.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"helyx",
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
"start": "node --env-file-if-exists=../../.env dist/index.js"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@helyx/config": "0.
|
|
51
|
+
"@helyx/config": "0.3.0",
|
|
52
52
|
"@helyx/control-protocol": "0.1.1",
|
|
53
|
-
"@helyx/core": "0.1.
|
|
54
|
-
"@helyx/database": "0.
|
|
55
|
-
"@helyx/framework": "0.1.
|
|
56
|
-
"@helyx/module-manifest": "0.
|
|
57
|
-
"@helyx/platform": "0.
|
|
58
|
-
"@helyx/sdk": "0.
|
|
53
|
+
"@helyx/core": "0.1.5",
|
|
54
|
+
"@helyx/database": "0.3.0",
|
|
55
|
+
"@helyx/framework": "0.1.5",
|
|
56
|
+
"@helyx/module-manifest": "0.4.0",
|
|
57
|
+
"@helyx/platform": "0.2.0",
|
|
58
|
+
"@helyx/sdk": "0.3.0",
|
|
59
59
|
"discord.js": "14.27.0",
|
|
60
60
|
"fastify": "5.10.0",
|
|
61
61
|
"pino": "10.3.1",
|