@helyx/bot 0.2.2 → 0.3.1

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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [60f1b23]
8
+ - @helyx/database@0.3.1
9
+ - @helyx/platform@0.2.1
10
+
11
+ ## 0.3.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 0229cb1: Add the versioned Helyx privacy-provider contract, strict write and nullable
16
+ read boundaries, guarded attribution migrations, bounded hosted and self-hosted
17
+ provider execution, and the Privacy Centre runtime configuration.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [0229cb1]
22
+ - @helyx/module-manifest@0.4.0
23
+ - @helyx/sdk@0.3.0
24
+ - @helyx/database@0.3.0
25
+ - @helyx/platform@0.2.0
26
+ - @helyx/config@0.3.0
27
+ - @helyx/core@0.1.5
28
+ - @helyx/framework@0.1.5
29
+
30
+ ## 0.2.3
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies
35
+ - @helyx/config@0.2.2
36
+ - @helyx/database@0.2.2
37
+ - @helyx/platform@0.1.6
38
+
3
39
  ## 0.2.2
4
40
 
5
41
  ### Patch Changes
package/README.md CHANGED
@@ -18,4 +18,10 @@ When `HELYX_BOT_CONTROL_SECRET` is configured, the bot also starts a private aut
18
18
 
19
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
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.
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.
@@ -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
@@ -2,7 +2,7 @@ import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
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";
@@ -33,16 +33,32 @@ export async function startBot(processEnvironment = process.env) {
33
33
  sslMode: environment.DATABASE_SSL,
34
34
  applicationName: "helyx-bot",
35
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;
36
46
  let databaseClosed = false;
37
47
  let retentionCleanupTimer;
38
48
  async function closeDatabase() {
39
49
  if (databaseClosed)
40
50
  return;
41
51
  databaseClosed = true;
52
+ if (privacyDatabase)
53
+ await privacyDatabase.end();
42
54
  await database.end();
43
55
  }
44
56
  try {
45
57
  await checkDatabaseConnection(database);
58
+ if (privacyDatabase) {
59
+ await checkDatabaseConnection(privacyDatabase);
60
+ await assertDistinctDatabaseRoles(database, privacyDatabase);
61
+ }
46
62
  const migrationResult = await runCoreMigrations(database);
47
63
  bootstrapLogger.info({
48
64
  event: "database.ready",
@@ -140,7 +156,13 @@ export async function startBot(processEnvironment = process.env) {
140
156
  prepare: prepareGuilds,
141
157
  }, bootstrapLogger, serviceAccess);
142
158
  installModuleEvents(client, modules, bootstrapLogger, serviceAccess);
143
- 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);
144
166
  cloudConnector.setControlHandler(controlService);
145
167
  let controlServer;
146
168
  let shuttingDown = false;
@@ -227,6 +249,15 @@ export async function startBot(processEnvironment = process.env) {
227
249
  throw error;
228
250
  }
229
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
+ }
230
261
  function readBotVersion() {
231
262
  const metadata = createRequire(import.meta.url)("../package.json");
232
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.2",
3
+ "version": "0.3.1",
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.1",
55
- "@helyx/framework": "0.1.4",
56
- "@helyx/module-manifest": "0.3.0",
57
- "@helyx/platform": "0.1.5",
58
- "@helyx/sdk": "0.2.4",
53
+ "@helyx/core": "0.1.5",
54
+ "@helyx/database": "0.3.1",
55
+ "@helyx/framework": "0.1.5",
56
+ "@helyx/module-manifest": "0.4.0",
57
+ "@helyx/platform": "0.2.1",
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",