@helyx/bot 0.2.2 → 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 +28 -0
- package/README.md +6 -0
- package/dist/control-server.d.ts +44 -2
- package/dist/control-server.js +82 -1
- package/dist/index.js +33 -2
- package/dist/modules.js +47 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
3
31
|
## 0.2.2
|
|
4
32
|
|
|
5
33
|
### 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.
|
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
|
@@ -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.
|
|
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",
|