@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 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;
@@ -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
- if (existing && !existing.revokedAt)
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: "installation.enrolled",
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
- const decipher = createDecipheriv("aes-256-gcm", dataKey, encrypted.iv);
602
- decipher.setAAD(Buffer.from(`helyx-cloud-connector:${installationId}`, "utf8"));
603
- decipher.setAuthTag(encrypted.tag);
604
- return Buffer.concat([
605
- decipher.update(encrypted.ciphertext),
606
- decipher.final(),
607
- ]).toString("utf8");
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 (await isDeploymentOwner(input.client, context.userId, input.configuredOwnerUserIds))
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
- async start() { },
415
- async stop() { },
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
@@ -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;
@@ -96,7 +96,8 @@ export class BotControlService {
96
96
  #installations;
97
97
  #permissions;
98
98
  #services;
99
- constructor(database, modules, resources, services) {
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.2.1",
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.2.1",
51
+ "@helyx/config": "0.3.0",
52
52
  "@helyx/control-protocol": "0.1.1",
53
- "@helyx/core": "0.1.4",
54
- "@helyx/database": "0.2.0",
55
- "@helyx/framework": "0.1.4",
56
- "@helyx/module-manifest": "0.3.0",
57
- "@helyx/platform": "0.1.4",
58
- "@helyx/sdk": "0.2.4",
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",