@helyx/bot 0.4.40 → 0.4.42

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.42
4
+
5
+ ### Patch Changes
6
+
7
+ - Include in-progress giveaway draws linked to a Discord user in privacy access and erasure processing, and reject modules that omit Discord identity record fields from their privacy declarations.
8
+
9
+ ## 0.4.41
10
+
11
+ ### Patch Changes
12
+
13
+ - Keep coordinated rolling deployments ready through remote shard ownership and atomically escalate terminal privacy-provider failures to staff review.
14
+
3
15
  ## 0.4.40
4
16
 
5
17
  ### Patch Changes
@@ -559,6 +559,7 @@ export declare function createBotControlServer(input: {
559
559
  level: string;
560
560
  };
561
561
  shardHealth?: () => Promise<ShardHealthSnapshot>;
562
+ readiness?: () => Promise<boolean>;
562
563
  guildCommands?: Parameters<typeof registerGuildCommandControlRoutes>[1];
563
564
  }): Promise<FastifyInstance>;
564
565
  export declare function mapBotControlError(error: unknown): {
@@ -15,6 +15,7 @@ import { assertVersion, dashboardActionPermissionId, declaredManagedResourcePerm
15
15
  import { dashboardCommands, } from "./dashboard-commands.js";
16
16
  import { availabilityCatalogueDefinitions, availabilityCatalogue, dashboardModuleStatus, disabledModuleSummary, moduleAvailabilityService, moduleAvailabilityStates, } from "./module-availability-control.js";
17
17
  import { registerGuildCommandControlRoutes } from "./guild-command-control-routes.js";
18
+ import { createPrivacyProviderRegistry, describePrivacyProviders, } from "./privacy-provider-registry.js";
18
19
  const snowflake = z.string().regex(/^\d{17,20}$/u);
19
20
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
20
21
  const MAX_DISCORD_SETTING_SELECTIONS = 25;
@@ -284,14 +285,7 @@ export class BotControlService {
284
285
  this.#installations = services.get(HELYX_SERVICE_NAMES.installations);
285
286
  this.#permissions = services.get(HELYX_SERVICE_NAMES.permissions);
286
287
  this.#services = services;
287
- this.#privacyProviders = new Map(privacyExecutor
288
- ? modules
289
- .filter((module) => module.privacy !== undefined)
290
- .map((module) => {
291
- const provider = module.privacy.create(privacyExecutor, this.#services);
292
- return [provider.id, provider];
293
- })
294
- : []);
288
+ this.#privacyProviders = createPrivacyProviderRegistry(modules, privacyExecutor, this.#services);
295
289
  }
296
290
  async cleanupOAuthConsumer(input) {
297
291
  const module = this.#modules.get(input.moduleId);
@@ -391,13 +385,7 @@ export class BotControlService {
391
385
  .then((receipt) => actionReceiptSchema.parse(receipt));
392
386
  }
393
387
  listPrivacyProviders() {
394
- return Promise.resolve([...this.#privacyProviders.values()]
395
- .map((provider) => ({
396
- id: provider.id,
397
- version: provider.version,
398
- containsPersonalData: provider.describe().containsPersonalData,
399
- }))
400
- .sort((left, right) => left.id.localeCompare(right.id)));
388
+ return Promise.resolve(describePrivacyProviders(this.#privacyProviders));
401
389
  }
402
390
  capturePrivacy(input) {
403
391
  const provider = this.#privacyProvider(input.providerId, input.providerVersion);
@@ -1209,10 +1197,18 @@ export async function createBotControlServer(input) {
1209
1197
  return reply.code(401).send({ message: "Control authentication failed" });
1210
1198
  });
1211
1199
  async function readiness(reply) {
1212
- if (!input.shardHealth)
1200
+ const deploymentReadiness = input.readiness;
1201
+ const shardHealth = input.shardHealth;
1202
+ if (!deploymentReadiness && !shardHealth)
1213
1203
  return { status: "ok" };
1214
1204
  try {
1215
- const health = await input.shardHealth();
1205
+ if (deploymentReadiness && (await deploymentReadiness()))
1206
+ return { status: "ok" };
1207
+ if (deploymentReadiness)
1208
+ return reply.code(503).send({ status: "unavailable" });
1209
+ if (!shardHealth)
1210
+ return reply.code(503).send({ status: "unavailable" });
1211
+ const health = await shardHealth();
1216
1212
  if (health.status === "healthy")
1217
1213
  return { status: "ok" };
1218
1214
  }
package/dist/modules.js CHANGED
@@ -204,6 +204,19 @@ function validatePrivacyParity(definition) {
204
204
  }
205
205
  }
206
206
  }
207
+ const subjectFieldsByCollection = new Map(definition.manifest.privacy.collections.map((collection) => [
208
+ collection.name,
209
+ new Set(collection.subjectFields.map(({ field }) => field)),
210
+ ]));
211
+ for (const [collectionName, collection] of Object.entries(definition.records ?? {})) {
212
+ const declaredSubjectFields = subjectFieldsByCollection.get(collectionName);
213
+ for (const field of Object.keys(collection.fields)) {
214
+ if (isDiscordIdentityRecordField(field) &&
215
+ !declaredSubjectFields?.has(field)) {
216
+ throw new Error(`Module record field containing a Discord identity is not declared for privacy access: ${collectionName}.${field}`);
217
+ }
218
+ }
219
+ }
207
220
  const unavailable = () => {
208
221
  throw new Error("Privacy provider executor is unavailable during loading");
209
222
  };
@@ -220,6 +233,11 @@ function validatePrivacyParity(definition) {
220
233
  throw new Error("Module manifest privacy declaration does not match executable provider");
221
234
  }
222
235
  }
236
+ function isDiscordIdentityRecordField(field) {
237
+ return (/(?:^|_)(?:user_id|member_id|subject_id)$/u.test(field) ||
238
+ field === "created_by" ||
239
+ field === "updated_by");
240
+ }
223
241
  function assertSameContributionIds(declared, executable, label) {
224
242
  if (new Set(executable).size !== executable.length) {
225
243
  throw new Error(`Module has duplicate executable ${label}`);
@@ -0,0 +1,8 @@
1
+ import type { ModuleDefinition, PrivacyProvider, PrivacyProviderExecutor, ServiceAccess } from "@helyx/sdk";
2
+ export declare function createPrivacyProviderRegistry(modules: readonly ModuleDefinition[], executor: PrivacyProviderExecutor | undefined, services: ServiceAccess): ReadonlyMap<string, PrivacyProvider>;
3
+ export declare function describePrivacyProviders(providers: ReadonlyMap<string, PrivacyProvider>): readonly {
4
+ id: string;
5
+ version: number;
6
+ containsPersonalData: boolean;
7
+ }[];
8
+ //# sourceMappingURL=privacy-provider-registry.d.ts.map
@@ -0,0 +1,20 @@
1
+ export function createPrivacyProviderRegistry(modules, executor, services) {
2
+ if (!executor)
3
+ return new Map();
4
+ return new Map(modules.flatMap((module) => {
5
+ if (!module.privacy)
6
+ return [];
7
+ const provider = module.privacy.create(executor, services);
8
+ return [[provider.id, provider]];
9
+ }));
10
+ }
11
+ export function describePrivacyProviders(providers) {
12
+ return [...providers.values()]
13
+ .map((provider) => ({
14
+ id: provider.id,
15
+ version: provider.version,
16
+ containsPersonalData: provider.describe().containsPersonalData,
17
+ }))
18
+ .sort((left, right) => left.id.localeCompare(right.id));
19
+ }
20
+ //# sourceMappingURL=privacy-provider-registry.js.map
@@ -2,7 +2,7 @@ import type { BotEnvironment } from "@helyx/config";
2
2
  import { type DatabasePool } from "@helyx/database";
3
3
  import type { ModuleDefinition, NumericWriteBehindCommittedBatch, ScheduledTaskExecutionResult } from "@helyx/sdk";
4
4
  import { buildApplicationCommandDefinitions } from "./interactions.js";
5
- import { type ShardCloudRequest, type ShardIdentity, type ShardProcessHealth } from "./shard-contracts.js";
5
+ import { type ShardCloudRequest, type ShardHealthSnapshot, type ShardIdentity, type ShardProcessHealth } from "./shard-contracts.js";
6
6
  import { type ShardCoordinator, type ShardGenerationDescriptor, type ShardLeaderLease, type ShardLease } from "./shard-coordinator.js";
7
7
  import type { BotHandle } from "./index.js";
8
8
  export declare const STEADY_STATE_LEASE_LOSS_BOUND_MS: number;
@@ -20,6 +20,11 @@ export declare function startShardSupervisor(environment: BotEnvironment, proces
20
20
  export declare function splitNumericCommittedBatch(batch: NumericWriteBehindCommittedBatch, index: number): NumericWriteBehindCommittedBatch;
21
21
  export declare function buildShardWorkerEnvironment(source: NodeJS.ProcessEnv, identity: ShardIdentity, restartCount: number): NodeJS.ProcessEnv;
22
22
  export declare function shardRuntimeCompositionHash(modules: readonly ModuleDefinition[], internalGuildId?: string, globalCommandMode?: Parameters<typeof buildApplicationCommandDefinitions>[2]): string;
23
+ export declare function coordinatedFollowerReady(input: {
24
+ health: ShardHealthSnapshot;
25
+ activeLeader: ShardLeaderLease | undefined;
26
+ supervisorId: string;
27
+ }): boolean;
23
28
  export declare function boundedRespawnDelay(attempt: number): number;
24
29
  export interface SteadyStateOwnershipRenewal {
25
30
  generationRenewed: boolean;
@@ -420,6 +420,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
420
420
  secret: environment.HELYX_BOT_CONTROL_SECRET,
421
421
  logger: { level: environment.LOG_LEVEL },
422
422
  shardHealth: buildHealth,
423
+ readiness: buildDeploymentReadiness,
423
424
  guildCommands: new GuildCommandReadService(database, environment.DISCORD_CLIENT_ID, () => guildCommandWorker?.getHealth(), environment.DISCORD_TOKEN, environment.HELYX_GUILD_COMMAND_WRITES_ENABLED, environment.HELYX_GUILD_COMMAND_WRITE_GUILD_IDS, environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all", commandModules),
424
425
  });
425
426
  await controlServer.listen({
@@ -1343,6 +1344,19 @@ export async function startShardSupervisor(environment, processEnvironment, work
1343
1344
  shards,
1344
1345
  });
1345
1346
  }
1347
+ async function buildDeploymentReadiness() {
1348
+ const health = await buildHealth();
1349
+ if (health.status === "healthy")
1350
+ return true;
1351
+ if (!coordinator)
1352
+ return false;
1353
+ const activeLeader = await withTimeout(coordinator.getLeaderLease(), LEASE_VALIDATION_TIMEOUT_MS).catch(() => undefined);
1354
+ return coordinatedFollowerReady({
1355
+ health,
1356
+ activeLeader,
1357
+ supervisorId,
1358
+ });
1359
+ }
1346
1360
  async function shutdown(reason = "programmatic") {
1347
1361
  if (shuttingDown)
1348
1362
  return;
@@ -1690,6 +1704,16 @@ function boundedOperationalHealth(health) {
1690
1704
  shards: health.shards.slice(0, 25),
1691
1705
  });
1692
1706
  }
1707
+ export function coordinatedFollowerReady(input) {
1708
+ return (input.health.scope === "coordinated" &&
1709
+ input.health.status === "degraded" &&
1710
+ input.health.readyShardCount === input.health.configuredShardCount &&
1711
+ input.health.coordination.redis === "healthy" &&
1712
+ input.health.coordination.database === "healthy" &&
1713
+ input.health.scheduler?.status === "unavailable" &&
1714
+ input.activeLeader?.generation === input.health.generation &&
1715
+ input.activeLeader.supervisorId !== input.supervisorId);
1716
+ }
1693
1717
  function wait(milliseconds) {
1694
1718
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
1695
1719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.40",
3
+ "version": "0.4.42",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",