@helyx/bot 0.4.9 → 0.4.13

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,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.13
4
+
5
+ ### Patch Changes
6
+
7
+ - Add the hosted-only Stage 11 Verification foundation: bounded signed challenge
8
+ contracts, restricted persistence and privacy traversal, shard-safe bot
9
+ delivery, delegated investigation permissions, verifier configuration and
10
+ health, and explicit exclusion from every self-hosted composition.
11
+ - Updated dependencies
12
+ - @helyx/control-protocol@0.3.0
13
+ - @helyx/module-manifest@0.6.0
14
+ - @helyx/config@0.5.0
15
+ - @helyx/database@0.6.0
16
+ - @helyx/sdk@0.5.0
17
+ - @helyx/platform@0.4.0
18
+ - @helyx/core@0.1.12
19
+ - @helyx/framework@0.1.12
20
+
21
+ ## 0.4.12
22
+
23
+ ### Patch Changes
24
+
25
+ - Repair hosted guild presence after removal, restart and shard reassignment with
26
+ bounded fenced reconciliation and customer-safe dashboard states.
27
+ - Exit with failure after an unexpected coordinated-shard lease shutdown so the
28
+ service orchestrator replaces the stopped supervisor automatically.
29
+ - Retry one transient lease-renewal transport failure within the existing
30
+ fencing window while still failing closed on an actual ownership mismatch.
31
+ - Updated dependencies
32
+ - @helyx/config@0.4.4
33
+ - @helyx/database@0.5.1
34
+ - @helyx/platform@0.3.1
35
+
36
+ ## 0.4.11
37
+
38
+ ### Patch Changes
39
+
40
+ - Add the reusable Stage 8 Levels foundations: privacy-bounded guild message
41
+ events with optional transient type classification, optional Discord-user
42
+ command options, multiple channel settings, bounded ranked numeric records,
43
+ idempotent numeric mutation receipts, and core-owned write-behind accumulation
44
+ across local and coordinated shard compositions.
45
+ - Updated dependencies
46
+ - @helyx/database@0.5.0
47
+ - @helyx/module-manifest@0.5.0
48
+ - @helyx/control-protocol@0.2.0
49
+ - @helyx/sdk@0.4.0
50
+ - @helyx/platform@0.3.0
51
+ - @helyx/core@0.1.11
52
+ - @helyx/framework@0.1.11
53
+
54
+ ## 0.4.10
55
+
56
+ ### Patch Changes
57
+
58
+ - Add the shared session, Discord event, resource inspection, enforcement,
59
+ privacy and hosted/self-hosted composition support required by the Account Gate
60
+ Testing module.
61
+ - Updated dependencies
62
+ - @helyx/config@0.4.3
63
+ - @helyx/database@0.4.4
64
+ - @helyx/sdk@0.3.5
65
+ - @helyx/platform@0.2.8
66
+ - @helyx/core@0.1.10
67
+ - @helyx/framework@0.1.10
68
+
3
69
  ## 0.4.9
4
70
 
5
71
  ### Patch Changes
@@ -146,11 +146,12 @@ export class CloudControlDispatcher {
146
146
  break;
147
147
  }
148
148
  case "guild.module.resource.action.execute.v1": {
149
- const input = parseOperationInput(request.operation, request.input);
149
+ const { actorAuthenticatedAt, ...input } = parseOperationInput(request.operation, request.input);
150
150
  result = await this.control.executeManagedResourceAction({
151
151
  ...input,
152
152
  actorUserId: requiredActor(actorUserId),
153
153
  correlationId,
154
+ ...(actorAuthenticatedAt ? { actorAuthenticatedAt } : {}),
154
155
  });
155
156
  break;
156
157
  }
@@ -1,5 +1,6 @@
1
- import { type DatabasePool } from "@helyx/database";
1
+ import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
2
2
  import { type DashboardActionResult, type ManagedResourceCreationIntent, type ManagedResourceDetail, type ManagedResourcePage, type ManagedResourceRelationPage, type ManagedResourceValue, type MessageResponse, type ModuleDefinition, type PermissionActor, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
3
+ import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verification-contracts";
3
4
  import { type Client } from "discord.js";
4
5
  import { type FastifyInstance } from "fastify";
5
6
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
@@ -63,7 +64,7 @@ export interface BotControlGuildResources {
63
64
  channels: Array<{
64
65
  id: string;
65
66
  name: string;
66
- type: "guild_text" | "guild_announcement";
67
+ type: "guild_text" | "guild_announcement" | "guild_category";
67
68
  }>;
68
69
  roles: Array<{
69
70
  id: string;
@@ -71,7 +72,14 @@ export interface BotControlGuildResources {
71
72
  colour: string;
72
73
  }>;
73
74
  }
75
+ export type BotGuildPresenceState = "installed" | "not_installed" | "unknown" | "unavailable";
76
+ export interface BotGuildPresenceObservation {
77
+ guildId: string;
78
+ state: BotGuildPresenceState;
79
+ observedAt: string;
80
+ }
74
81
  export interface BotControlGuildResourceDirectory {
82
+ listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
75
83
  getGuildResources(guildId: string): Promise<BotControlGuildResources>;
76
84
  sendToChannel(channelId: string, response: MessageResponse): Promise<{
77
85
  messageId: string;
@@ -85,6 +93,7 @@ export interface BotControlGuildResourceDirectory {
85
93
  export declare class DiscordGuildResourceDirectory implements BotControlGuildResourceDirectory {
86
94
  private readonly client;
87
95
  constructor(client: Client);
96
+ listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
88
97
  getGuildResources(guildId: string): Promise<BotControlGuildResources>;
89
98
  sendToChannel(channelId: string, response: MessageResponse): Promise<{
90
99
  messageId: string;
@@ -104,6 +113,9 @@ export interface SupportNotificationInput {
104
113
  source: "dashboard_bug" | "dashboard_support" | "dashboard_security" | "discord_command";
105
114
  }
106
115
  export interface BotControlState {
116
+ discordCapabilities?: {
117
+ messageContent: boolean;
118
+ };
107
119
  channels: BotControlGuildResources["channels"];
108
120
  roles: BotControlGuildResources["roles"];
109
121
  modules: DashboardModuleDefinition[];
@@ -117,7 +129,18 @@ export interface BotControlState {
117
129
  }
118
130
  export interface BotControlHandler {
119
131
  sendSupportNotification(input: SupportNotificationInput): Promise<void>;
132
+ listGuildPresence(input: {
133
+ guildIds: readonly string[];
134
+ correlationId: string;
135
+ }): Promise<BotGuildPresenceObservation[]>;
120
136
  getGuildState(guildId: string): Promise<BotControlState>;
137
+ canAccessManagedResource(input: {
138
+ guildId: string;
139
+ moduleId: string;
140
+ resourceType: string;
141
+ operation: "list" | "read";
142
+ actorUserId: string;
143
+ }): Promise<boolean>;
121
144
  setModuleStatus(input: {
122
145
  guildId: string;
123
146
  moduleId: string;
@@ -237,7 +260,9 @@ export interface BotControlHandler {
237
260
  value: Readonly<Record<string, ManagedResourceValue>>;
238
261
  actorUserId: string;
239
262
  correlationId: string;
263
+ actorAuthenticatedAt?: string;
240
264
  }): Promise<ManagedResourceDetail | null>;
265
+ acceptVerificationResult(envelope: SignedAssessmentEnvelope): Promise<ActionReceipt>;
241
266
  listPrivacyProviders(): Promise<readonly {
242
267
  id: string;
243
268
  version: number;
@@ -269,11 +294,18 @@ export declare class BotControlService implements BotControlHandler {
269
294
  private readonly database;
270
295
  private readonly resources;
271
296
  private readonly supportNotifications?;
297
+ private readonly presenceOwnership?;
298
+ private readonly presenceShardCount?;
272
299
  constructor(database: DatabasePool, modules: readonly ModuleDefinition[], resources: BotControlGuildResourceDirectory, services: ServiceAccess, privacyExecutor?: PrivacyProviderExecutor, supportNotifications?: {
273
300
  guildId: string;
274
301
  channelId: string;
275
- } | undefined);
302
+ } | undefined, presenceOwnership?: GuildShardOwnership | undefined, presenceShardCount?: number | undefined);
303
+ listGuildPresence(input: {
304
+ guildIds: readonly string[];
305
+ correlationId: string;
306
+ }): Promise<BotGuildPresenceObservation[]>;
276
307
  sendSupportNotification(input: SupportNotificationInput): Promise<void>;
308
+ acceptVerificationResult(envelopeInput: SignedAssessmentEnvelope): Promise<ActionReceipt>;
277
309
  listPrivacyProviders(): Promise<readonly {
278
310
  id: string;
279
311
  version: number;
@@ -296,6 +328,13 @@ export declare class BotControlService implements BotControlHandler {
296
328
  cursor?: string;
297
329
  }): Promise<PrivacyErasePage>;
298
330
  getGuildState(guildId: string): Promise<BotControlState>;
331
+ canAccessManagedResource(input: {
332
+ guildId: string;
333
+ moduleId: string;
334
+ resourceType: string;
335
+ operation: "list" | "read";
336
+ actorUserId: string;
337
+ }): Promise<boolean>;
299
338
  setModuleStatus(input: {
300
339
  guildId: string;
301
340
  moduleId: string;
@@ -415,6 +454,7 @@ export declare class BotControlService implements BotControlHandler {
415
454
  value: Readonly<Record<string, ManagedResourceValue>>;
416
455
  actorUserId: string;
417
456
  correlationId: string;
457
+ actorAuthenticatedAt?: string;
418
458
  }): Promise<ManagedResourceDetail | null>;
419
459
  }
420
460
  export declare function createBotControlServer(input: {
@@ -1,5 +1,6 @@
1
1
  import { AuditRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
2
2
  import { DashboardActionValidationError, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
+ import { actionReceiptSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
3
4
  import { canUseCommand } from "@helyx/core";
4
5
  import { ChannelType, PermissionFlagsBits } from "discord.js";
5
6
  import Fastify, {} from "fastify";
@@ -9,6 +10,7 @@ import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteCh
9
10
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
10
11
  const snowflake = z.string().regex(/^\d{17,20}$/u);
11
12
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
13
+ const MAX_DISCORD_SETTING_SELECTIONS = 25;
12
14
  const configurationValue = z.record(z.string(), z.union([
13
15
  z.null(),
14
16
  z.string(),
@@ -48,7 +50,7 @@ const resourceCursor = z.string().min(1).max(2_048);
48
50
  const resourceActionId = z.string().regex(/^[a-z][a-z0-9_-]*$/u);
49
51
  const managedResourceFilters = z
50
52
  .record(z.string().regex(/^[a-z][a-zA-Z0-9_]*$/u), z.string().trim().min(1).max(100))
51
- .refine((value) => Object.keys(value).length <= 5);
53
+ .refine((value) => Object.keys(value).length <= 8);
52
54
  const managedResourceValue = z.custom((value) => isBoundedManagedResourceValue(value), "Managed resource value exceeds the transport limits");
53
55
  const managedResourceObject = z
54
56
  .record(z.string().min(1).max(100), managedResourceValue)
@@ -97,6 +99,24 @@ export class DiscordGuildResourceDirectory {
97
99
  constructor(client) {
98
100
  this.client = client;
99
101
  }
102
+ listGuildPresence(guildIds) {
103
+ const observedAt = new Date().toISOString();
104
+ const ready = this.client.isReady();
105
+ return Promise.resolve(guildIds.map((guildId) => {
106
+ const guild = this.client.guilds.cache.get(guildId);
107
+ return {
108
+ guildId,
109
+ state: !ready
110
+ ? "unknown"
111
+ : !guild
112
+ ? "not_installed"
113
+ : guild.available
114
+ ? "installed"
115
+ : "unavailable",
116
+ observedAt,
117
+ };
118
+ }));
119
+ }
100
120
  async getGuildResources(guildId) {
101
121
  try {
102
122
  const guild = await this.client.guilds.fetch(guildId);
@@ -107,13 +127,16 @@ export class DiscordGuildResourceDirectory {
107
127
  return {
108
128
  channels: [...channels.values()]
109
129
  .filter((channel) => channel?.type === ChannelType.GuildText ||
110
- channel?.type === ChannelType.GuildAnnouncement)
130
+ channel?.type === ChannelType.GuildAnnouncement ||
131
+ channel?.type === ChannelType.GuildCategory)
111
132
  .map((channel) => ({
112
133
  id: channel.id,
113
134
  name: channel.name,
114
135
  type: channel.type === ChannelType.GuildAnnouncement
115
136
  ? "guild_announcement"
116
- : "guild_text",
137
+ : channel.type === ChannelType.GuildCategory
138
+ ? "guild_category"
139
+ : "guild_text",
117
140
  }))
118
141
  .sort((left, right) => left.name.localeCompare(right.name)),
119
142
  roles: [...roles.values()]
@@ -200,6 +223,8 @@ export class BotControlService {
200
223
  database;
201
224
  resources;
202
225
  supportNotifications;
226
+ presenceOwnership;
227
+ presenceShardCount;
203
228
  #modules;
204
229
  #dashboardModules;
205
230
  #configurations;
@@ -207,10 +232,12 @@ export class BotControlService {
207
232
  #permissions;
208
233
  #services;
209
234
  #privacyProviders;
210
- constructor(database, modules, resources, services, privacyExecutor, supportNotifications) {
235
+ constructor(database, modules, resources, services, privacyExecutor, supportNotifications, presenceOwnership, presenceShardCount) {
211
236
  this.database = database;
212
237
  this.resources = resources;
213
238
  this.supportNotifications = supportNotifications;
239
+ this.presenceOwnership = presenceOwnership;
240
+ this.presenceShardCount = presenceShardCount;
214
241
  this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
215
242
  this.#dashboardModules = modules
216
243
  .filter((module) => module.manifest.dashboard !== undefined)
@@ -223,11 +250,45 @@ export class BotControlService {
223
250
  ? modules
224
251
  .filter((module) => module.privacy !== undefined)
225
252
  .map((module) => {
226
- const provider = module.privacy.create(privacyExecutor);
253
+ const provider = module.privacy.create(privacyExecutor, this.#services);
227
254
  return [provider.id, provider];
228
255
  })
229
256
  : []);
230
257
  }
258
+ async listGuildPresence(input) {
259
+ const guildIds = [...new Set(input.guildIds)];
260
+ if (guildIds.length > 200)
261
+ throw new BotControlValidationError("Guild presence requests cannot exceed 200 guilds");
262
+ const observations = await this.resources.listGuildPresence(guildIds);
263
+ const returned = new Set(observations.map(({ guildId }) => guildId));
264
+ const complete = guildIds.map((guildId) => observations.find((item) => item.guildId === guildId) ?? {
265
+ guildId,
266
+ state: "unknown",
267
+ observedAt: new Date().toISOString(),
268
+ });
269
+ if (observations.some(({ guildId }) => !guildIds.includes(guildId)) ||
270
+ returned.size !== observations.length)
271
+ throw new BotControlTransportError(502, "The guild presence response was invalid");
272
+ const absent = complete
273
+ .filter(({ state }) => state === "not_installed")
274
+ .map(({ guildId }) => guildId);
275
+ if (absent.length > 0) {
276
+ const repository = new InstallationRepository(this.database);
277
+ if (this.presenceOwnership)
278
+ await repository.adoptGuildsForReconciliation({
279
+ guildIds: absent,
280
+ ownership: this.presenceOwnership,
281
+ shardCount: this.presenceShardCount,
282
+ });
283
+ await repository.markGuildsRemovedForReconciliation({
284
+ guildIds: absent,
285
+ ...(this.presenceOwnership
286
+ ? { ownership: this.presenceOwnership }
287
+ : {}),
288
+ });
289
+ }
290
+ return complete;
291
+ }
231
292
  async sendSupportNotification(input) {
232
293
  if (!this.supportNotifications)
233
294
  throw new BotControlNotFoundError("The support notification channel is not configured");
@@ -262,6 +323,15 @@ export class BotControlService {
262
323
  },
263
324
  }, input.notificationId);
264
325
  }
326
+ acceptVerificationResult(envelopeInput) {
327
+ if (!this.#services.has(HELYX_SERVICE_NAMES.verification))
328
+ throw new BotControlTransportError(503, "Verification result handling is unavailable");
329
+ const envelope = signedAssessmentEnvelopeSchema.parse(envelopeInput);
330
+ return this.#services
331
+ .get(HELYX_SERVICE_NAMES.verification)
332
+ .acceptResult(envelope)
333
+ .then((receipt) => actionReceiptSchema.parse(receipt));
334
+ }
265
335
  listPrivacyProviders() {
266
336
  return Promise.resolve([...this.#privacyProviders.values()]
267
337
  .map((provider) => ({
@@ -290,10 +360,11 @@ export class BotControlService {
290
360
  });
291
361
  }
292
362
  async getGuildState(guildId) {
293
- const [resources, installedModules, storedLoggingPolicies] = await Promise.all([
363
+ const [resources, installedModules, storedLoggingPolicies, runtime] = await Promise.all([
294
364
  this.resources.getGuildResources(guildId),
295
365
  new InstallationRepository(this.database).listModules(guildId),
296
366
  new ModuleLoggingPolicyRepository(this.database).listForGuild(guildId),
367
+ this.#inspectRuntimeCapabilities(),
297
368
  ]);
298
369
  const installed = new Map(installedModules.map((module) => [module.moduleId, module]));
299
370
  const moduleStatuses = {};
@@ -332,6 +403,9 @@ export class BotControlService {
332
403
  });
333
404
  }));
334
405
  return {
406
+ discordCapabilities: {
407
+ messageContent: runtime.messageContent,
408
+ },
335
409
  ...resources,
336
410
  modules: structuredClone(this.#dashboardModules),
337
411
  moduleStatuses,
@@ -340,9 +414,42 @@ export class BotControlService {
340
414
  logging,
341
415
  };
342
416
  }
417
+ async canAccessManagedResource(input) {
418
+ try {
419
+ const { module } = await this.#prepareManagedResource(input);
420
+ this.#requireManagedResourceOperation(module, input.resourceType, input.operation);
421
+ await this.#authorizeAction(input.guildId, module, input.actorUserId, managedResourcePermissionId(module, input.resourceType, input.operation));
422
+ return true;
423
+ }
424
+ catch (error) {
425
+ if (error instanceof BotControlForbiddenError)
426
+ return false;
427
+ throw error;
428
+ }
429
+ }
430
+ async #inspectRuntimeCapabilities() {
431
+ if (!this.#services.has(HELYX_SERVICE_NAMES.discordResources))
432
+ return { messageContent: false };
433
+ const inspector = this.#services.get(HELYX_SERVICE_NAMES.discordResources);
434
+ if (typeof inspector.inspectRuntimeCapabilities !== "function")
435
+ return { messageContent: false };
436
+ const runtime = await inspector.inspectRuntimeCapabilities();
437
+ return { messageContent: runtime.messageContent };
438
+ }
343
439
  async setModuleStatus(input) {
344
- this.#requireDashboardModule(input.moduleId);
345
- await this.#installations.setModuleEnabled({
440
+ const module = this.#requireDashboardModule(input.moduleId);
441
+ const scopes = Object.entries(module.records ?? {})
442
+ .filter(([, collection]) => (collection.incrementableFields?.length ?? 0) > 0)
443
+ .map(([collection]) => ({
444
+ moduleId: input.moduleId,
445
+ collection,
446
+ guildId: input.guildId,
447
+ }));
448
+ const writeBehind = scopes.length > 0 &&
449
+ this.#services.has(HELYX_SERVICE_NAMES.numericWriteBehind)
450
+ ? this.#services.get(HELYX_SERVICE_NAMES.numericWriteBehind)
451
+ : null;
452
+ const setEnabled = () => this.#installations.setModuleEnabled({
346
453
  guildId: input.guildId,
347
454
  moduleId: input.moduleId,
348
455
  enabled: input.enabled,
@@ -352,11 +459,56 @@ export class BotControlService {
352
459
  source: "dashboard",
353
460
  },
354
461
  });
462
+ if (!writeBehind) {
463
+ await setEnabled();
464
+ }
465
+ else if (input.enabled) {
466
+ for (const scope of scopes) {
467
+ const suppression = await writeBehind.suppressGuild(scope);
468
+ await writeBehind.releaseGuild({
469
+ ...scope,
470
+ suppressionId: suppression.suppressionId,
471
+ });
472
+ }
473
+ await setEnabled();
474
+ }
475
+ else {
476
+ const runBoundary = async (index) => {
477
+ const scope = scopes[index];
478
+ if (!scope) {
479
+ await setEnabled();
480
+ return;
481
+ }
482
+ await writeBehind.runGuildBarrier(scope, () => runBoundary(index + 1));
483
+ };
484
+ await runBoundary(0);
485
+ for (const scope of scopes)
486
+ await writeBehind.flushGuild(scope);
487
+ for (const scope of scopes)
488
+ await writeBehind.suppressGuild(scope);
489
+ }
355
490
  return this.getGuildState(input.guildId);
356
491
  }
357
492
  async updateConfiguration(input) {
358
493
  const module = this.#requireDashboardModule(input.moduleId);
359
494
  await this.#validateDiscordSettings(input.guildId, module, input.value);
495
+ if (module.configuration?.validateUpdate) {
496
+ try {
497
+ const value = module.configuration.validate(input.value);
498
+ await module.configuration.validateUpdate({
499
+ guildId: input.guildId,
500
+ value,
501
+ services: this.#services,
502
+ });
503
+ }
504
+ catch (error) {
505
+ if (error instanceof ModuleStateValidationError)
506
+ throw error;
507
+ throw new DashboardActionValidationError(error instanceof Error
508
+ ? error.message
509
+ : "The module rejected these settings.");
510
+ }
511
+ }
360
512
  const saved = await this.#configurations.update({
361
513
  guildId: input.guildId,
362
514
  moduleId: input.moduleId,
@@ -603,15 +755,13 @@ export class BotControlService {
603
755
  }));
604
756
  }
605
757
  async executeManagedResourceAction(input) {
606
- const module = this.#requireDashboardModule(input.moduleId);
758
+ const { module, resource } = await this.#prepareManagedResource(input);
607
759
  const declared = module.manifest.dashboard?.resources
608
760
  ?.find(({ id }) => id === input.resourceType)
609
761
  ?.actions?.find(({ id }) => id === input.actionId);
610
- const resource = this.#requireManagedResource(module, input.resourceType);
611
762
  this.#requireManagedResourceOperation(module, input.resourceType, "action");
612
763
  if (!declared)
613
764
  throw new BotControlNotFoundError("Managed resource action is not declared by this module");
614
- await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
615
765
  const permissionId = managedResourcePermissionId(module, input.resourceType, "action", input.actionId, declared.permissionId);
616
766
  const context = await this.#managedResourceContext(input, module, "action", permissionId);
617
767
  validateManagedResourceActionValue(declared.inputs ?? [], input.value);
@@ -628,7 +778,8 @@ export class BotControlService {
628
778
  async #prepareManagedResource(input) {
629
779
  const module = this.#requireDashboardModule(input.moduleId);
630
780
  const resource = this.#requireManagedResource(module, input.resourceType);
631
- await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
781
+ if (!resource.availableWhileDisabled)
782
+ await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
632
783
  return { module, resource };
633
784
  }
634
785
  #requireManagedResource(module, resourceType) {
@@ -667,6 +818,10 @@ export class BotControlService {
667
818
  return {
668
819
  guildId: input.guildId,
669
820
  actor,
821
+ ...("actorAuthenticatedAt" in input &&
822
+ typeof input.actorAuthenticatedAt === "string"
823
+ ? { actorAuthenticatedAt: input.actorAuthenticatedAt }
824
+ : {}),
670
825
  correlationId: input.correlationId,
671
826
  services: this.#services,
672
827
  sendToChannel: (channelId, response) => this.resources.sendToChannel(channelId, response),
@@ -734,14 +889,25 @@ export class BotControlService {
734
889
  if (setting.type === "discord-channel") {
735
890
  if (configured === "" || configured === undefined)
736
891
  continue;
737
- const channel = typeof configured === "string" ? channels.get(configured) : null;
738
- if (!channel || !setting.channelTypes.includes(channel.type))
739
- throw new BotControlValidationError(`${setting.label} must be an available supported Discord channel`);
892
+ if (Boolean(setting.multiple) !== Array.isArray(configured))
893
+ throw new BotControlValidationError(`${setting.label} has an invalid Discord channel selection`);
894
+ const configuredChannels = Array.isArray(configured)
895
+ ? configured
896
+ : [configured];
897
+ if (configuredChannels.length > MAX_DISCORD_SETTING_SELECTIONS ||
898
+ new Set(configuredChannels).size !== configuredChannels.length ||
899
+ configuredChannels.some((channelId) => {
900
+ if (typeof channelId !== "string")
901
+ return true;
902
+ const channel = channels.get(channelId);
903
+ return !channel || !setting.channelTypes.includes(channel.type);
904
+ }))
905
+ throw new BotControlValidationError(`${setting.label} must contain available supported Discord channels`);
740
906
  }
741
907
  if (setting.type === "discord-role") {
742
908
  if (configured === "" || configured === undefined)
743
909
  continue;
744
- if (setting.multiple !== Array.isArray(configured))
910
+ if (Boolean(setting.multiple) !== Array.isArray(configured))
745
911
  throw new BotControlValidationError(`${setting.label} has an invalid Discord role selection`);
746
912
  const configuredRoles = Array.isArray(configured)
747
913
  ? configured
@@ -810,6 +976,7 @@ export async function createBotControlServer(input) {
810
976
  }
811
977
  server.get("/health", (_request, reply) => readiness(reply));
812
978
  server.get("/v1/health", (_request, reply) => readiness(reply));
979
+ server.post("/v1/verification/results", async (request) => input.service.acceptVerificationResult(signedAssessmentEnvelopeSchema.parse(request.body)));
813
980
  server.get("/v1/shards/health", async (request, reply) => {
814
981
  reply.header("Cache-Control", "no-store");
815
982
  if (!input.shardHealth)
@@ -874,6 +1041,18 @@ export async function createBotControlServer(input) {
874
1041
  await input.service.sendSupportNotification(body);
875
1042
  return reply.code(204).send();
876
1043
  });
1044
+ server.post("/v1/guilds/presence", async (request) => {
1045
+ const body = z
1046
+ .object({
1047
+ guildIds: z.array(snowflake).min(1).max(200),
1048
+ correlationId: z.uuid(),
1049
+ })
1050
+ .strict()
1051
+ .parse(request.body);
1052
+ return {
1053
+ items: await input.service.listGuildPresence(body),
1054
+ };
1055
+ });
877
1056
  server.post("/v1/privacy/capture", async (request) => {
878
1057
  const body = z
879
1058
  .object({
@@ -882,11 +1061,13 @@ export async function createBotControlServer(input) {
882
1061
  requestId: z.uuid(),
883
1062
  subjectUserId: snowflake,
884
1063
  cutoffAt: z.coerce.date(),
1064
+ operation: z.enum(["capture", "residual_scan"]).optional(),
885
1065
  cursor: z.string().min(1).max(2_048).optional(),
886
1066
  })
887
1067
  .strict()
888
1068
  .parse(request.body);
889
- const { cursor, ...value } = body;
1069
+ const { cursor, operation: _operation, ...value } = body;
1070
+ void _operation;
890
1071
  return input.service.capturePrivacy({
891
1072
  ...value,
892
1073
  ...(cursor ? { cursor } : {}),
@@ -914,6 +1095,28 @@ export async function createBotControlServer(input) {
914
1095
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
915
1096
  return input.service.getGuildState(guildId);
916
1097
  });
1098
+ server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/access", async (request) => {
1099
+ const params = z
1100
+ .object({
1101
+ guildId: snowflake,
1102
+ moduleId,
1103
+ resourceType: z.string().regex(/^[a-z][a-z0-9-]*$/u),
1104
+ })
1105
+ .parse(request.params);
1106
+ const body = z
1107
+ .object({
1108
+ operation: z.enum(["list", "read"]),
1109
+ actorUserId: snowflake,
1110
+ })
1111
+ .strict()
1112
+ .parse(request.body);
1113
+ return {
1114
+ allowed: await input.service.canAccessManagedResource({
1115
+ ...params,
1116
+ ...body,
1117
+ }),
1118
+ };
1119
+ });
917
1120
  server.put("/v1/guilds/:guildId/modules/:moduleId/status", async (request) => {
918
1121
  const params = z
919
1122
  .object({ guildId: snowflake, moduleId })
@@ -1101,12 +1304,19 @@ export async function createBotControlServer(input) {
1101
1304
  .extend({
1102
1305
  expectedRevision: z.number().int().min(0),
1103
1306
  value: managedResourceObject.optional().default({}),
1307
+ actorAuthenticatedAt: z.iso.datetime({ offset: true }).optional(),
1104
1308
  })
1105
1309
  .strict()
1106
1310
  .parse(request.body);
1107
1311
  return input.service.executeManagedResourceAction({
1108
1312
  ...params,
1109
- ...body,
1313
+ actorUserId: body.actorUserId,
1314
+ correlationId: body.correlationId,
1315
+ expectedRevision: body.expectedRevision,
1316
+ value: body.value,
1317
+ ...(body.actorAuthenticatedAt === undefined
1318
+ ? {}
1319
+ : { actorAuthenticatedAt: body.actorAuthenticatedAt }),
1110
1320
  });
1111
1321
  });
1112
1322
  server.put("/v1/guilds/:guildId/modules/:moduleId/configuration", async (request) => {