@helyx/bot 0.4.27 → 0.4.29

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.29
4
+
5
+ ### Patch Changes
6
+
7
+ - Harden dashboard authentication, privacy execution, control routing, database transactions, and self-host configuration.
8
+
9
+ Add bounded multi-value record reads and use them to batch Polls and Reaction Roles dashboard queries. Improve dashboard presentation, Forms workflows, release safety, and operational tooling across hosted and self-hosted deployments.
10
+
11
+ - Updated dependencies
12
+ - @helyx/control-protocol@0.4.0
13
+ - @helyx/database@0.8.0
14
+ - @helyx/sdk@0.7.0
15
+ - @helyx/platform@0.5.0
16
+
17
+ ## 0.4.28
18
+
19
+ ### Patch Changes
20
+
21
+ - Add the private testing-stage Forms module contracts and runtime integration,
22
+ and introduce server-configurable Helyx Administrator, Moderator, dashboard
23
+ access, and command-permission policies with fresh Discord authorization.
24
+
3
25
  ## 0.4.27
4
26
 
5
27
  ### Patch Changes
@@ -719,6 +719,9 @@ function connectorCapabilities() {
719
719
  return [
720
720
  "installation.health.read.v1",
721
721
  "guild.state.read.v1",
722
+ "guild.dashboard.access.read.v1",
723
+ "guild.access-policy.read.v1",
724
+ "guild.access-policy.write.v1",
722
725
  "guild.module.public.read.v1",
723
726
  "guild.module.status.write.v1",
724
727
  "guild.module.configuration.write.v1",
@@ -25,6 +25,33 @@ export class CloudControlDispatcher {
25
25
  result = await this.control.getGuildState(input.guildId);
26
26
  break;
27
27
  }
28
+ case "guild.dashboard.access.read.v1": {
29
+ const input = parseOperationInput(request.operation, request.input);
30
+ result = {
31
+ allowed: await this.control.canAccessDashboard({
32
+ guildId: input.guildId,
33
+ actorUserId: requiredActor(actorUserId),
34
+ }),
35
+ };
36
+ break;
37
+ }
38
+ case "guild.access-policy.read.v1": {
39
+ const input = parseOperationInput(request.operation, request.input);
40
+ result = await this.control.getGuildAccessPolicy({
41
+ guildId: input.guildId,
42
+ actorUserId: requiredActor(actorUserId),
43
+ });
44
+ break;
45
+ }
46
+ case "guild.access-policy.write.v1": {
47
+ const input = parseOperationInput(request.operation, request.input);
48
+ result = await this.control.updateGuildAccessPolicy({
49
+ ...input,
50
+ actorUserId: requiredActor(actorUserId),
51
+ correlationId,
52
+ });
53
+ break;
54
+ }
28
55
  case "guild.module.public.read.v1": {
29
56
  const input = parseOperationInput(request.operation, request.input);
30
57
  if (request.actor.kind !== "public")
@@ -116,6 +143,8 @@ export class CloudControlDispatcher {
116
143
  resourceId: input.resourceId,
117
144
  relationId: input.relationId,
118
145
  ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
146
+ ...(input.search === undefined ? {} : { search: input.search }),
147
+ ...(input.filters === undefined ? {} : { filters: input.filters }),
119
148
  limit: input.limit,
120
149
  actorUserId: requiredActor(actorUserId),
121
150
  correlationId,
@@ -1,5 +1,5 @@
1
1
  import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
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 PublicAchievementProjectionPage, type ServiceAccess } from "@helyx/sdk";
2
+ import { type DashboardActionResult, type GuildAccessPolicy, type ManagedResourceCreationIntent, type ManagedResourceDetail, type ManagedResourcePage, type ManagedResourceRelationPage, type ManagedResourceValue, type MessageResponse, type ModuleDefinition, type PermissionActor, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type PublicAchievementProjectionPage, type ServiceAccess } from "@helyx/sdk";
3
3
  import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verification-contracts";
4
4
  import { type Client } from "discord.js";
5
5
  import { type FastifyInstance } from "fastify";
@@ -45,6 +45,7 @@ interface DashboardModuleDefinition {
45
45
  actions: DashboardActionDefinition[];
46
46
  achievementTriggers: NonNullable<ModuleDefinition["manifest"]["contributions"]["achievementTriggers"]>;
47
47
  resources: readonly DashboardResourceDefinition[];
48
+ workspace?: NonNullable<ModuleDefinition["manifest"]["dashboard"]>["workspace"];
48
49
  }
49
50
  interface DashboardLoggingPolicy {
50
51
  eventId: string;
@@ -71,6 +72,7 @@ export interface BotControlGuildResources {
71
72
  id: string;
72
73
  name: string;
73
74
  colour: string;
75
+ position: number;
74
76
  }>;
75
77
  }
76
78
  export interface BotPublicModuleProjection {
@@ -146,6 +148,14 @@ export interface BotControlState {
146
148
  permissions: Record<string, DashboardPermissionState>;
147
149
  logging: Record<string, DashboardLoggingPolicy[]>;
148
150
  }
151
+ export interface DashboardGuildAccessState extends GuildAccessPolicy {
152
+ version: number;
153
+ canManage: boolean;
154
+ actorAuthority: "owner" | "administrator" | "moderator" | "member";
155
+ actorRoleIds: string[];
156
+ actorHighestRolePosition: number | null;
157
+ updatedAt: string | null;
158
+ }
149
159
  export interface BotControlHandler {
150
160
  sendSupportNotification(input: SupportNotificationInput): Promise<void>;
151
161
  listGuildPresence(input: {
@@ -153,6 +163,21 @@ export interface BotControlHandler {
153
163
  correlationId: string;
154
164
  }): Promise<BotGuildPresenceObservation[]>;
155
165
  getGuildState(guildId: string): Promise<BotControlState>;
166
+ canAccessDashboard(input: {
167
+ guildId: string;
168
+ actorUserId: string;
169
+ }): Promise<boolean>;
170
+ getGuildAccessPolicy(input: {
171
+ guildId: string;
172
+ actorUserId: string;
173
+ }): Promise<DashboardGuildAccessState>;
174
+ updateGuildAccessPolicy(input: {
175
+ guildId: string;
176
+ actorUserId: string;
177
+ correlationId: string;
178
+ version: number;
179
+ policy: GuildAccessPolicy;
180
+ }): Promise<DashboardGuildAccessState>;
156
181
  readPublicModuleProjection(input: {
157
182
  guildId: string;
158
183
  moduleId: string;
@@ -235,6 +260,8 @@ export interface BotControlHandler {
235
260
  relationId: string;
236
261
  cursor?: string;
237
262
  limit: number;
263
+ search?: string;
264
+ filters?: Readonly<Record<string, string>>;
238
265
  actorUserId: string;
239
266
  correlationId: string;
240
267
  }): Promise<ManagedResourceRelationPage>;
@@ -354,6 +381,21 @@ export declare class BotControlService implements BotControlHandler {
354
381
  cursor?: string;
355
382
  }): Promise<PrivacyErasePage>;
356
383
  getGuildState(guildId: string): Promise<BotControlState>;
384
+ canAccessDashboard(input: {
385
+ guildId: string;
386
+ actorUserId: string;
387
+ }): Promise<boolean>;
388
+ getGuildAccessPolicy(input: {
389
+ guildId: string;
390
+ actorUserId: string;
391
+ }): Promise<DashboardGuildAccessState>;
392
+ updateGuildAccessPolicy(input: {
393
+ guildId: string;
394
+ actorUserId: string;
395
+ correlationId: string;
396
+ version: number;
397
+ policy: GuildAccessPolicy;
398
+ }): Promise<DashboardGuildAccessState>;
357
399
  readPublicModuleProjection(input: {
358
400
  guildId: string;
359
401
  moduleId: string;
@@ -436,6 +478,8 @@ export declare class BotControlService implements BotControlHandler {
436
478
  relationId: string;
437
479
  cursor?: string;
438
480
  limit: number;
481
+ search?: string;
482
+ filters?: Readonly<Record<string, string>>;
439
483
  actorUserId: string;
440
484
  correlationId: string;
441
485
  }): Promise<ManagedResourceRelationPage>;
@@ -1,13 +1,14 @@
1
- import { AuditRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
2
- import { DashboardActionValidationError, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
1
+ import { AuditRepository, GuildAccessPolicyRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
2
+ import { DashboardActionValidationError, DEFAULT_GUILD_ACCESS_POLICY, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
3
  import { actionReceiptSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
4
- import { canUseCommand } from "@helyx/core";
4
+ import { canAccessGuildDashboard, canMutatePermissionPolicy, canUseCommand, permissionMutationDenialMessage, resolveHelyxAuthority, } from "@helyx/core";
5
5
  import { ChannelType, PermissionFlagsBits } from "discord.js";
6
6
  import Fastify, {} from "fastify";
7
7
  import { createHash, timingSafeEqual } from "node:crypto";
8
8
  import { z, ZodError } from "zod";
9
9
  import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteChannelMessage as deleteDiscordChannelMessage, editChannelMessage as editDiscordChannelMessage, normalizeDiscordPermission, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
10
10
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
11
+ import { dashboardCategoryLabel } from "./dashboard-category.js";
11
12
  const snowflake = z.string().regex(/^\d{17,20}$/u);
12
13
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
13
14
  const MAX_DISCORD_SETTING_SELECTIONS = 25;
@@ -41,6 +42,15 @@ const permissionState = z.object({
41
42
  }))
42
43
  .max(100),
43
44
  });
45
+ const guildAccessPolicy = z
46
+ .object({
47
+ administratorRoleIds: z.array(snowflake).max(25),
48
+ moderatorRoleIds: z.array(snowflake).max(25),
49
+ dashboardModeratorsEnabled: z.boolean(),
50
+ dashboardAllowedRoleIds: z.array(snowflake).max(25),
51
+ dashboardDeniedRoleIds: z.array(snowflake).max(25),
52
+ })
53
+ .strict();
44
54
  const mutationContext = z.object({
45
55
  actorUserId: snowflake,
46
56
  correlationId: z.uuid(),
@@ -146,6 +156,7 @@ export class DiscordGuildResourceDirectory {
146
156
  id: role.id,
147
157
  name: role.name,
148
158
  colour: role.hexColor,
159
+ position: role.position,
149
160
  })),
150
161
  };
151
162
  }
@@ -181,7 +192,13 @@ export class DiscordGuildResourceDirectory {
181
192
  async resolveActor(guildId, actorUserId) {
182
193
  try {
183
194
  const guild = await this.client.guilds.fetch(guildId);
184
- const member = await guild.members.fetch(actorUserId);
195
+ // Dashboard authorization must reflect current Discord roles and
196
+ // permissions even when a member-update gateway event was missed.
197
+ const member = await guild.members.fetch({
198
+ user: actorUserId,
199
+ force: true,
200
+ cache: true,
201
+ });
185
202
  return {
186
203
  userId: member.id,
187
204
  roleIds: [...member.roles.cache.keys()],
@@ -190,6 +207,7 @@ export class DiscordGuildResourceDirectory {
190
207
  .map(normalizeDiscordPermission),
191
208
  isServerOwner: guild.ownerId === member.id,
192
209
  isAdministrator: member.permissions.has(PermissionFlagsBits.Administrator),
210
+ highestRolePosition: member.roles.highest.position,
193
211
  };
194
212
  }
195
213
  catch (error) {
@@ -425,6 +443,88 @@ export class BotControlService {
425
443
  logging,
426
444
  };
427
445
  }
446
+ async canAccessDashboard(input) {
447
+ const actor = await this.resources.resolveActor(input.guildId, input.actorUserId);
448
+ return canAccessGuildDashboard(actor, await this.#readGuildAccessPolicy(input.guildId));
449
+ }
450
+ async getGuildAccessPolicy(input) {
451
+ const actor = await this.resources.resolveActor(input.guildId, input.actorUserId);
452
+ const record = await new GuildAccessPolicyRepository(this.database).get(input.guildId);
453
+ const policy = record ?? DEFAULT_GUILD_ACCESS_POLICY;
454
+ return {
455
+ administratorRoleIds: [...policy.administratorRoleIds],
456
+ moderatorRoleIds: [...policy.moderatorRoleIds],
457
+ dashboardModeratorsEnabled: policy.dashboardModeratorsEnabled,
458
+ dashboardAllowedRoleIds: [...policy.dashboardAllowedRoleIds],
459
+ dashboardDeniedRoleIds: [...policy.dashboardDeniedRoleIds],
460
+ version: record ? safeVersion(record.version) : 0,
461
+ canManage: actor.isServerOwner || actor.isAdministrator,
462
+ actorAuthority: resolveHelyxAuthority(actor, policy),
463
+ actorRoleIds: [...actor.roleIds],
464
+ actorHighestRolePosition: actor.highestRolePosition ?? null,
465
+ updatedAt: record?.updatedAt.toISOString() ?? null,
466
+ };
467
+ }
468
+ async updateGuildAccessPolicy(input) {
469
+ const actor = await this.resources.resolveActor(input.guildId, input.actorUserId);
470
+ if (!actor.isServerOwner && !actor.isAdministrator)
471
+ throw new BotControlForbiddenError("Only the server owner or a Discord Administrator can change dashboard access");
472
+ const resources = await this.resources.getGuildResources(input.guildId);
473
+ const availableRoles = new Set(resources.roles.map(({ id }) => id));
474
+ const roleIds = [
475
+ ...input.policy.administratorRoleIds,
476
+ ...input.policy.moderatorRoleIds,
477
+ ...input.policy.dashboardAllowedRoleIds,
478
+ ...input.policy.dashboardDeniedRoleIds,
479
+ ];
480
+ if (roleIds.some((roleId) => !availableRoles.has(roleId)))
481
+ throw new BotControlValidationError("Dashboard access contains a Discord role that is no longer available");
482
+ const saved = await withTransaction(this.database, async (connection) => {
483
+ const record = await new GuildAccessPolicyRepository(connection).save({
484
+ guildId: input.guildId,
485
+ administratorRoleIds: [...input.policy.administratorRoleIds],
486
+ moderatorRoleIds: [...input.policy.moderatorRoleIds],
487
+ dashboardModeratorsEnabled: input.policy.dashboardModeratorsEnabled,
488
+ dashboardAllowedRoleIds: [...input.policy.dashboardAllowedRoleIds],
489
+ dashboardDeniedRoleIds: [...input.policy.dashboardDeniedRoleIds],
490
+ expectedVersion: input.version === 0 ? null : BigInt(input.version),
491
+ updatedBy: input.actorUserId,
492
+ });
493
+ await new AuditRepository(connection).append({
494
+ guildId: input.guildId,
495
+ actorUserId: input.actorUserId,
496
+ action: "guild.access.updated",
497
+ source: "dashboard",
498
+ correlationId: input.correlationId,
499
+ afterState: {
500
+ version: record.version.toString(),
501
+ administratorRoleCount: record.administratorRoleIds.length,
502
+ moderatorRoleCount: record.moderatorRoleIds.length,
503
+ dashboardAllowedRoleCount: record.dashboardAllowedRoleIds.length,
504
+ dashboardDeniedRoleCount: record.dashboardDeniedRoleIds.length,
505
+ dashboardModeratorsEnabled: record.dashboardModeratorsEnabled,
506
+ },
507
+ });
508
+ return record;
509
+ });
510
+ return {
511
+ administratorRoleIds: [...saved.administratorRoleIds],
512
+ moderatorRoleIds: [...saved.moderatorRoleIds],
513
+ dashboardModeratorsEnabled: saved.dashboardModeratorsEnabled,
514
+ dashboardAllowedRoleIds: [...saved.dashboardAllowedRoleIds],
515
+ dashboardDeniedRoleIds: [...saved.dashboardDeniedRoleIds],
516
+ version: safeVersion(saved.version),
517
+ canManage: true,
518
+ actorAuthority: resolveHelyxAuthority(actor, saved),
519
+ actorRoleIds: [...actor.roleIds],
520
+ actorHighestRolePosition: actor.highestRolePosition ?? null,
521
+ updatedAt: saved.updatedAt.toISOString(),
522
+ };
523
+ }
524
+ async #readGuildAccessPolicy(guildId) {
525
+ return ((await new GuildAccessPolicyRepository(this.database).get(guildId)) ??
526
+ DEFAULT_GUILD_ACCESS_POLICY);
527
+ }
428
528
  async readPublicModuleProjection(input) {
429
529
  const module = this.#requireModule(input.moduleId);
430
530
  if (!module.publicProjection)
@@ -583,6 +683,26 @@ export class BotControlService {
583
683
  ])));
584
684
  for (const command of commands)
585
685
  assertVersion(currentCommands.get(command.id).version, supplied.get(command.id).version);
686
+ const [actor, guildAccess, resources] = await Promise.all([
687
+ this.resources.resolveActor(input.guildId, input.actorUserId),
688
+ this.#permissions.getGuildAccessPolicy?.(input.guildId) ??
689
+ Promise.resolve(DEFAULT_GUILD_ACCESS_POLICY),
690
+ this.resources.getGuildResources(input.guildId),
691
+ ]);
692
+ const rolePositions = Object.fromEntries(resources.roles.map((role) => [role.id, role.position]));
693
+ const requestedModulePolicy = fromDashboardPolicy(input.state.module, currentModule.policy);
694
+ if (!permissionPoliciesEqual(currentModule.policy, requestedModulePolicy))
695
+ assertPermissionMutationAllowed(canMutatePermissionPolicy(actor, currentModule.policy, requestedModulePolicy, guildAccess, rolePositions));
696
+ for (const command of commands) {
697
+ const requested = supplied.get(command.id);
698
+ const current = currentCommands.get(command.id);
699
+ const target = requested.enabled
700
+ ? fromDashboardPolicy(requested.policy, current.policy)
701
+ : requestedModulePolicy;
702
+ const enabledChanged = requested.enabled === current.inherited;
703
+ if (enabledChanged || !permissionPoliciesEqual(current.policy, target))
704
+ assertPermissionMutationAllowed(canMutatePermissionPolicy(actor, current.policy, target, guildAccess, rolePositions));
705
+ }
586
706
  await withTransaction(this.database, async (connection) => {
587
707
  const policies = new PermissionPolicyRepository(connection);
588
708
  const audit = new AuditRepository(connection);
@@ -765,6 +885,8 @@ export class BotControlService {
765
885
  return normalizeManagedResourceRelationPage(managedResourceRelationPageSchema.parse(await resource.listRelation(context, input.resourceId, input.relationId, {
766
886
  ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
767
887
  limit: input.limit,
888
+ ...(input.search === undefined ? {} : { search: input.search }),
889
+ ...(input.filters === undefined ? {} : { filters: input.filters }),
768
890
  })), relation.columns.map(({ key }) => key));
769
891
  }
770
892
  async previewManagedResource(input) {
@@ -877,7 +999,8 @@ export class BotControlService {
877
999
  async #authorizeAction(guildId, module, actorUserId, permissionId) {
878
1000
  const actor = await this.resources.resolveActor(guildId, actorUserId);
879
1001
  const policy = await this.#permissions.getEffectivePolicy(guildId, module.manifest.id, permissionId);
880
- if (!canUseCommand(actor, policy))
1002
+ const guildAccess = await this.#permissions.getGuildAccessPolicy?.(guildId);
1003
+ if (!canUseCommand(actor, policy, guildAccess))
881
1004
  throw new BotControlForbiddenError();
882
1005
  return actor;
883
1006
  }
@@ -1139,6 +1262,35 @@ export async function createBotControlServer(input) {
1139
1262
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1140
1263
  return input.service.getGuildState(guildId);
1141
1264
  });
1265
+ server.post("/v1/guilds/:guildId/dashboard-access", async (request) => {
1266
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1267
+ const { actorUserId } = z
1268
+ .object({ actorUserId: snowflake })
1269
+ .strict()
1270
+ .parse(request.body);
1271
+ return {
1272
+ allowed: await input.service.canAccessDashboard({
1273
+ guildId,
1274
+ actorUserId,
1275
+ }),
1276
+ };
1277
+ });
1278
+ server.get("/v1/guilds/:guildId/access-policy", async (request) => {
1279
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1280
+ const { actorUserId } = z
1281
+ .object({ actorUserId: snowflake })
1282
+ .strict()
1283
+ .parse(request.query);
1284
+ return input.service.getGuildAccessPolicy({ guildId, actorUserId });
1285
+ });
1286
+ server.put("/v1/guilds/:guildId/access-policy", async (request) => {
1287
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1288
+ const body = mutationContext
1289
+ .extend({ version: z.number().int().min(0), policy: guildAccessPolicy })
1290
+ .strict()
1291
+ .parse(request.body);
1292
+ return input.service.updateGuildAccessPolicy({ guildId, ...body });
1293
+ });
1142
1294
  server.get("/v1/guilds/:guildId/modules/:moduleId/public-projection", async (request) => {
1143
1295
  const params = z
1144
1296
  .object({ guildId: snowflake, moduleId })
@@ -1272,6 +1424,21 @@ export async function createBotControlServer(input) {
1272
1424
  .object({
1273
1425
  cursor: resourceCursor.optional(),
1274
1426
  limit: z.coerce.number().int().min(1).max(50).default(50),
1427
+ search: z.string().trim().min(1).max(200).optional(),
1428
+ filters: z
1429
+ .preprocess((value) => {
1430
+ if (typeof value !== "string")
1431
+ return value;
1432
+ if (value.length > 2_048)
1433
+ return value;
1434
+ try {
1435
+ return JSON.parse(value);
1436
+ }
1437
+ catch {
1438
+ return value;
1439
+ }
1440
+ }, managedResourceFilters)
1441
+ .optional(),
1275
1442
  actorUserId: snowflake,
1276
1443
  correlationId: z.uuid(),
1277
1444
  })
@@ -1281,6 +1448,8 @@ export async function createBotControlServer(input) {
1281
1448
  ...params,
1282
1449
  ...(query.cursor === undefined ? {} : { cursor: query.cursor }),
1283
1450
  limit: query.limit,
1451
+ ...(query.search === undefined ? {} : { search: query.search }),
1452
+ ...(query.filters === undefined ? {} : { filters: query.filters }),
1284
1453
  actorUserId: query.actorUserId,
1285
1454
  correlationId: query.correlationId,
1286
1455
  });
@@ -1657,7 +1826,7 @@ function mapDashboardModule(module) {
1657
1826
  name: module.manifest.name,
1658
1827
  version: module.manifest.version,
1659
1828
  summary: module.manifest.description,
1660
- category: dashboard.category[0].toUpperCase() + dashboard.category.slice(1),
1829
+ category: dashboardCategoryLabel(dashboard.category),
1661
1830
  icon: dashboard.icon,
1662
1831
  availability: "available",
1663
1832
  releaseStatus: module.manifest.releaseStatus,
@@ -1668,6 +1837,9 @@ function mapDashboardModule(module) {
1668
1837
  actions: (dashboard.actions ?? []).map((action) => ({ ...action })),
1669
1838
  achievementTriggers: (module.manifest.contributions?.achievementTriggers ?? []).map((trigger) => structuredClone(trigger)),
1670
1839
  resources: (dashboard.resources ?? []).map((resource) => structuredClone(resource)),
1840
+ workspace: dashboard.workspace
1841
+ ? structuredClone(dashboard.workspace)
1842
+ : undefined,
1671
1843
  };
1672
1844
  }
1673
1845
  function dashboardCommands(module) {
@@ -1702,14 +1874,18 @@ function dashboardCommands(module) {
1702
1874
  function toDashboardPolicy(policy) {
1703
1875
  const preset = policy.everyone
1704
1876
  ? "everyone"
1705
- : policy.allowedRoleIds.length > 0 &&
1706
- policy.requiredPermissions.length === 0
1707
- ? "custom"
1708
- : policy.requiredPermissions.some((permission) => ["ADMINISTRATOR", "MANAGE_GUILD"].includes(permission))
1709
- ? "administrators"
1710
- : policy.requiredPermissions.length > 0
1711
- ? "moderators"
1712
- : "custom";
1877
+ : policy.accessLevel === "administrator"
1878
+ ? "administrators"
1879
+ : policy.accessLevel === "moderator"
1880
+ ? "moderators"
1881
+ : policy.allowedRoleIds.length > 0 &&
1882
+ policy.requiredPermissions.length === 0
1883
+ ? "custom"
1884
+ : policy.requiredPermissions.includes("ADMINISTRATOR")
1885
+ ? "administrators"
1886
+ : policy.requiredPermissions.includes("MANAGE_GUILD")
1887
+ ? "moderators"
1888
+ : "custom";
1713
1889
  return {
1714
1890
  preset,
1715
1891
  allowedRoleIds: [...policy.allowedRoleIds],
@@ -1719,11 +1895,12 @@ function toDashboardPolicy(policy) {
1719
1895
  function fromDashboardPolicy(input, current) {
1720
1896
  return {
1721
1897
  everyone: input.preset === "everyone",
1722
- requiredPermissions: input.preset === "administrators"
1723
- ? ["MANAGE_GUILD"]
1898
+ ...(input.preset === "administrators"
1899
+ ? { accessLevel: "administrator" }
1724
1900
  : input.preset === "moderators"
1725
- ? ["MANAGE_MESSAGES"]
1726
- : [],
1901
+ ? { accessLevel: "moderator" }
1902
+ : {}),
1903
+ requiredPermissions: [],
1727
1904
  allowedRoleIds: input.preset === "custom" ? input.allowedRoleIds : [],
1728
1905
  deniedRoleIds: input.deniedRoleIds,
1729
1906
  allowedUserIds: [...current.allowedUserIds],
@@ -1740,6 +1917,24 @@ function emptyPolicy() {
1740
1917
  deniedUserIds: [],
1741
1918
  };
1742
1919
  }
1920
+ function permissionPoliciesEqual(left, right) {
1921
+ return (left.everyone === right.everyone &&
1922
+ left.accessLevel === right.accessLevel &&
1923
+ sameStrings(left.requiredPermissions, right.requiredPermissions) &&
1924
+ sameStrings(left.allowedRoleIds, right.allowedRoleIds) &&
1925
+ sameStrings(left.deniedRoleIds, right.deniedRoleIds) &&
1926
+ sameStrings(left.allowedUserIds, right.allowedUserIds) &&
1927
+ sameStrings(left.deniedUserIds, right.deniedUserIds));
1928
+ }
1929
+ function sameStrings(left, right) {
1930
+ return (left.length === right.length &&
1931
+ left.every((value, index) => value === right[index]));
1932
+ }
1933
+ function assertPermissionMutationAllowed(decision) {
1934
+ if (decision.allowed)
1935
+ return;
1936
+ throw new BotControlForbiddenError(permissionMutationDenialMessage(decision.reason));
1937
+ }
1743
1938
  function safeVersion(version) {
1744
1939
  const value = Number(version);
1745
1940
  if (!Number.isSafeInteger(value) || value < 1)
@@ -0,0 +1,3 @@
1
+ import type { ModuleManifest } from "@helyx/module-manifest";
2
+ export declare function dashboardCategoryLabel(category: NonNullable<ModuleManifest["dashboard"]>["category"]): string;
3
+ //# sourceMappingURL=dashboard-category.d.ts.map
@@ -0,0 +1,14 @@
1
+ const categoryLabels = {
2
+ community: "Community",
3
+ safety: "Safety",
4
+ support: "Support & Staff",
5
+ automation: "Automation",
6
+ engagement: "Engagement",
7
+ gaming: "Gaming",
8
+ insights: "Insights",
9
+ integrations: "Integrations",
10
+ };
11
+ export function dashboardCategoryLabel(category) {
12
+ return categoryLabels[category];
13
+ }
14
+ //# sourceMappingURL=dashboard-category.js.map
@@ -372,6 +372,9 @@ function createContext(interaction, services) {
372
372
  userRoleIds: interaction.member instanceof GuildMember
373
373
  ? [...interaction.member.roles.cache.keys()]
374
374
  : (interaction.member?.roles ?? []),
375
+ ...(interaction.member instanceof GuildMember
376
+ ? { userHighestRolePosition: interaction.member.roles.highest.position }
377
+ : {}),
375
378
  userPermissions: interaction.memberPermissions
376
379
  ?.toArray()
377
380
  .map(normalizeDiscordPermission) ?? [],
@@ -491,6 +494,9 @@ function createModalContext(interaction, services) {
491
494
  userRoleIds: interaction.member instanceof GuildMember
492
495
  ? [...interaction.member.roles.cache.keys()]
493
496
  : (interaction.member?.roles ?? []),
497
+ ...(interaction.member instanceof GuildMember
498
+ ? { userHighestRolePosition: interaction.member.roles.highest.position }
499
+ : {}),
494
500
  userPermissions: interaction.memberPermissions
495
501
  ?.toArray()
496
502
  .map(normalizeDiscordPermission) ?? [],
@@ -525,6 +531,12 @@ function createModalContext(interaction, services) {
525
531
  roleIds: [...(selected?.roles.keys() ?? [])],
526
532
  };
527
533
  },
534
+ getSelectedRolePositions: (customId) => {
535
+ const roles = interaction.fields.getSelectedRoles(customId, false);
536
+ return Object.fromEntries([...(roles?.values() ?? [])]
537
+ .filter((role) => role !== null)
538
+ .map((role) => [role.id, role.position]));
539
+ },
528
540
  getSelectedChannelIds: (customId) => [
529
541
  ...(interaction.fields.getSelectedChannels(customId, false)?.keys() ??
530
542
  []),
@@ -624,7 +636,8 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
624
636
  const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
625
637
  const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
626
638
  const actor = permissionActor(interaction);
627
- if (!canUseCommand(actor, policy)) {
639
+ const guildAccess = await permissions.getGuildAccessPolicy?.(interaction.guildId);
640
+ if (!canUseCommand(actor, policy, guildAccess)) {
628
641
  await interaction.reply({
629
642
  content: "You do not have permission to use this command.",
630
643
  flags: MessageFlags.Ephemeral,
@@ -662,7 +675,8 @@ async function authorizeAutocomplete(interaction, moduleId, commandId, services,
662
675
  return true;
663
676
  const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
664
677
  const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
665
- return canUseCommand(permissionActor(interaction), policy);
678
+ const guildAccess = await permissions.getGuildAccessPolicy?.(interaction.guildId);
679
+ return canUseCommand(permissionActor(interaction), policy, guildAccess);
666
680
  }
667
681
  function permissionActor(interaction) {
668
682
  return {
@@ -676,6 +690,9 @@ function permissionActor(interaction) {
676
690
  isServerOwner: interaction.guild?.ownerId === interaction.user.id,
677
691
  isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ??
678
692
  false,
693
+ ...(interaction.member instanceof GuildMember
694
+ ? { highestRolePosition: interaction.member.roles.highest.position }
695
+ : {}),
679
696
  };
680
697
  }
681
698
  export function normalizeDiscordPermission(permission) {
@@ -824,100 +841,116 @@ export function toModal(definition) {
824
841
  const modal = new ModalBuilder()
825
842
  .setCustomId(definition.customId)
826
843
  .setTitle(definition.title);
827
- for (const text of definition.text ?? []) {
828
- modal.addTextDisplayComponents(new TextDisplayBuilder().setContent(text.markdown === false ? escapeMarkdown(text.content) : text.content));
829
- }
830
- for (const field of definition.fields) {
831
- const label = new LabelBuilder().setLabel(field.label);
832
- if (field.description !== undefined)
833
- label.setDescription(field.description);
834
- if (field.type === undefined || field.type === "text") {
835
- const input = new TextInputBuilder()
836
- .setCustomId(field.customId)
837
- .setStyle(field.style === "paragraph"
838
- ? TextInputStyle.Paragraph
839
- : TextInputStyle.Short)
840
- .setRequired(field.required ?? false);
841
- if (field.minLength !== undefined)
842
- input.setMinLength(field.minLength);
843
- if (field.maxLength !== undefined)
844
- input.setMaxLength(field.maxLength);
845
- if (field.placeholder !== undefined)
846
- input.setPlaceholder(field.placeholder);
847
- if (field.value !== undefined)
848
- input.setValue(field.value);
849
- label.setTextInputComponent(input);
850
- }
851
- else if (field.type === "string-select") {
852
- const input = new StringSelectMenuBuilder()
853
- .setCustomId(field.customId)
854
- .setRequired(field.required ?? false)
855
- .setMinValues(field.minValues ?? (field.required ? 1 : 0))
856
- .setMaxValues(field.maxValues ?? 1)
857
- .addOptions(field.options.map((option) => ({ ...option })));
858
- if (field.placeholder !== undefined)
859
- input.setPlaceholder(field.placeholder);
860
- label.setStringSelectMenuComponent(input);
861
- }
862
- else if (field.type === "discord-user") {
863
- const input = new UserSelectMenuBuilder()
864
- .setCustomId(field.customId)
865
- .setRequired(field.required ?? false)
866
- .setMinValues(field.minValues ?? (field.required ? 1 : 0))
867
- .setMaxValues(field.maxValues ?? 1);
868
- if (field.placeholder !== undefined)
869
- input.setPlaceholder(field.placeholder);
870
- if (field.defaultValues?.length)
871
- input.setDefaultUsers(...field.defaultValues);
872
- label.setUserSelectMenuComponent(input);
873
- }
874
- else if (field.type === "discord-role") {
875
- const input = new RoleSelectMenuBuilder()
876
- .setCustomId(field.customId)
877
- .setRequired(field.required ?? false)
878
- .setMinValues(field.minValues ?? (field.required ? 1 : 0))
879
- .setMaxValues(field.maxValues ?? 1);
880
- if (field.placeholder !== undefined)
881
- input.setPlaceholder(field.placeholder);
882
- if (field.defaultValues?.length)
883
- input.setDefaultRoles(...field.defaultValues);
884
- label.setRoleSelectMenuComponent(input);
885
- }
886
- else if (field.type === "discord-mentionable") {
887
- const input = new MentionableSelectMenuBuilder()
888
- .setCustomId(field.customId)
889
- .setRequired(field.required ?? false)
890
- .setMinValues(field.minValues ?? (field.required ? 1 : 0))
891
- .setMaxValues(field.maxValues ?? 1);
892
- if (field.placeholder !== undefined)
893
- input.setPlaceholder(field.placeholder);
894
- if (field.defaultValues?.length) {
895
- input.addDefaultUsers(...field.defaultValues
896
- .filter((value) => value.type === "user")
897
- .map((value) => value.id));
898
- input.addDefaultRoles(...field.defaultValues
899
- .filter((value) => value.type === "role")
900
- .map((value) => value.id));
901
- }
902
- label.setMentionableSelectMenuComponent(input);
903
- }
904
- else if (field.type === "discord-channel") {
905
- const input = new ChannelSelectMenuBuilder()
906
- .setCustomId(field.customId)
907
- .setRequired(field.required ?? false)
908
- .setMinValues(field.minValues ?? (field.required ? 1 : 0))
909
- .setMaxValues(field.maxValues ?? 1)
910
- .setChannelTypes(...field.channelTypes.map(discordChannelType));
911
- if (field.placeholder !== undefined)
912
- input.setPlaceholder(field.placeholder);
913
- if (field.defaultValues?.length)
914
- input.setDefaultChannels(...field.defaultValues);
915
- label.setChannelSelectMenuComponent(input);
844
+ if (definition.items !== undefined &&
845
+ ((definition.text?.length ?? 0) > 0 || definition.fields.length > 0))
846
+ throw new Error("Ordered modal items cannot be combined with legacy text or fields");
847
+ const items = definition.items ?? [
848
+ ...(definition.text ?? []).map((text) => ({
849
+ type: "text-display",
850
+ ...text,
851
+ })),
852
+ ...definition.fields,
853
+ ];
854
+ if (items.length > 5)
855
+ throw new Error("Discord modals support at most five components");
856
+ for (const item of items) {
857
+ if (item.type !== "text-display") {
858
+ modal.addLabelComponents(toModalLabel(item));
859
+ continue;
916
860
  }
917
- modal.addLabelComponents(label);
861
+ modal.addTextDisplayComponents(new TextDisplayBuilder().setContent(item.markdown === false ? escapeMarkdown(item.content) : item.content));
918
862
  }
919
863
  return modal;
920
864
  }
865
+ function toModalLabel(field) {
866
+ const label = new LabelBuilder().setLabel(field.label);
867
+ if (field.description !== undefined)
868
+ label.setDescription(field.description);
869
+ if (field.type === undefined || field.type === "text") {
870
+ const input = new TextInputBuilder()
871
+ .setCustomId(field.customId)
872
+ .setStyle(field.style === "paragraph"
873
+ ? TextInputStyle.Paragraph
874
+ : TextInputStyle.Short)
875
+ .setRequired(field.required ?? false);
876
+ if (field.minLength !== undefined)
877
+ input.setMinLength(field.minLength);
878
+ if (field.maxLength !== undefined)
879
+ input.setMaxLength(field.maxLength);
880
+ if (field.placeholder !== undefined)
881
+ input.setPlaceholder(field.placeholder);
882
+ if (field.value !== undefined)
883
+ input.setValue(field.value);
884
+ label.setTextInputComponent(input);
885
+ }
886
+ else if (field.type === "string-select") {
887
+ const input = new StringSelectMenuBuilder()
888
+ .setCustomId(field.customId)
889
+ .setRequired(field.required ?? false)
890
+ .setMinValues(field.minValues ?? (field.required ? 1 : 0))
891
+ .setMaxValues(field.maxValues ?? 1)
892
+ .addOptions(field.options.map((option) => ({ ...option })));
893
+ if (field.placeholder !== undefined)
894
+ input.setPlaceholder(field.placeholder);
895
+ label.setStringSelectMenuComponent(input);
896
+ }
897
+ else if (field.type === "discord-user") {
898
+ const input = new UserSelectMenuBuilder()
899
+ .setCustomId(field.customId)
900
+ .setRequired(field.required ?? false)
901
+ .setMinValues(field.minValues ?? (field.required ? 1 : 0))
902
+ .setMaxValues(field.maxValues ?? 1);
903
+ if (field.placeholder !== undefined)
904
+ input.setPlaceholder(field.placeholder);
905
+ if (field.defaultValues?.length)
906
+ input.setDefaultUsers(...field.defaultValues);
907
+ label.setUserSelectMenuComponent(input);
908
+ }
909
+ else if (field.type === "discord-role") {
910
+ const input = new RoleSelectMenuBuilder()
911
+ .setCustomId(field.customId)
912
+ .setRequired(field.required ?? false)
913
+ .setMinValues(field.minValues ?? (field.required ? 1 : 0))
914
+ .setMaxValues(field.maxValues ?? 1);
915
+ if (field.placeholder !== undefined)
916
+ input.setPlaceholder(field.placeholder);
917
+ if (field.defaultValues?.length)
918
+ input.setDefaultRoles(...field.defaultValues);
919
+ label.setRoleSelectMenuComponent(input);
920
+ }
921
+ else if (field.type === "discord-mentionable") {
922
+ const input = new MentionableSelectMenuBuilder()
923
+ .setCustomId(field.customId)
924
+ .setRequired(field.required ?? false)
925
+ .setMinValues(field.minValues ?? (field.required ? 1 : 0))
926
+ .setMaxValues(field.maxValues ?? 1);
927
+ if (field.placeholder !== undefined)
928
+ input.setPlaceholder(field.placeholder);
929
+ if (field.defaultValues?.length) {
930
+ input.addDefaultUsers(...field.defaultValues
931
+ .filter((value) => value.type === "user")
932
+ .map((value) => value.id));
933
+ input.addDefaultRoles(...field.defaultValues
934
+ .filter((value) => value.type === "role")
935
+ .map((value) => value.id));
936
+ }
937
+ label.setMentionableSelectMenuComponent(input);
938
+ }
939
+ else if (field.type === "discord-channel") {
940
+ const input = new ChannelSelectMenuBuilder()
941
+ .setCustomId(field.customId)
942
+ .setRequired(field.required ?? false)
943
+ .setMinValues(field.minValues ?? (field.required ? 1 : 0))
944
+ .setMaxValues(field.maxValues ?? 1)
945
+ .setChannelTypes(...field.channelTypes.map(discordChannelType));
946
+ if (field.placeholder !== undefined)
947
+ input.setPlaceholder(field.placeholder);
948
+ if (field.defaultValues?.length)
949
+ input.setDefaultChannels(...field.defaultValues);
950
+ label.setChannelSelectMenuComponent(input);
951
+ }
952
+ return label;
953
+ }
921
954
  function discordChannelType(type) {
922
955
  if (type === "guild_announcement")
923
956
  return ChannelType.GuildAnnouncement;
@@ -1,5 +1,6 @@
1
1
  import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
2
  import { PermissionFlagsBits } from "discord.js";
3
+ import { canMutatePermissionPolicy, permissionMutationDenialMessage, } from "@helyx/core";
3
4
  const MODULE_SELECT_ID = "helyx.permissions.module";
4
5
  const TARGET_SELECT_ID = "helyx.permissions.target";
5
6
  const MODULE_PREVIOUS_ID = "helyx.permissions.module.previous";
@@ -243,9 +244,32 @@ export function createPermissionsModule(configurableModules) {
243
244
  allowedUserIds: [...allowed.userIds],
244
245
  deniedUserIds: [...denied.userIds],
245
246
  };
246
- await context.services
247
- .get(HELYX_SERVICE_NAMES.permissions)
248
- .setPolicy({
247
+ const permissionService = context.services.get(HELYX_SERVICE_NAMES.permissions);
248
+ const current = await permissionService.getPolicy(context.serverId, moduleId, commandId);
249
+ const guildAccess = (await permissionService.getGuildAccessPolicy?.(context.serverId)) ?? {
250
+ administratorRoleIds: [],
251
+ moderatorRoleIds: [],
252
+ dashboardModeratorsEnabled: false,
253
+ dashboardAllowedRoleIds: [],
254
+ dashboardDeniedRoleIds: [],
255
+ };
256
+ const rolePositions = {
257
+ ...(context.getSelectedRolePositions?.(ALLOWED_MENTIONABLES_FIELD_ID) ?? {}),
258
+ ...(context.getSelectedRolePositions?.(DENIED_MENTIONABLES_FIELD_ID) ?? {}),
259
+ };
260
+ const decision = canMutatePermissionPolicy({
261
+ userId: context.userId,
262
+ roleIds: context.userRoleIds,
263
+ permissions: context.userPermissions ?? [],
264
+ isServerOwner: context.isServerOwner ?? false,
265
+ isAdministrator: context.isAdministrator,
266
+ ...(context.userHighestRolePosition === undefined
267
+ ? {}
268
+ : { highestRolePosition: context.userHighestRolePosition }),
269
+ }, current.policy, policy, guildAccess, rolePositions);
270
+ if (!decision.allowed)
271
+ throw new Error(permissionMutationDenialMessage(decision.reason));
272
+ await permissionService.setPolicy({
249
273
  guildId: context.serverId,
250
274
  moduleId,
251
275
  ...(commandId ? { commandId } : {}),
@@ -326,7 +350,8 @@ export function createPermissionsModule(configurableModules) {
326
350
  function restrictedPolicy() {
327
351
  return {
328
352
  everyone: false,
329
- requiredPermissions: ["MANAGE_GUILD"],
353
+ accessLevel: "moderator",
354
+ requiredPermissions: [],
330
355
  allowedRoleIds: [],
331
356
  deniedRoleIds: [],
332
357
  allowedUserIds: [],
@@ -6,7 +6,8 @@ export class CoreResourceAuthorizationService {
6
6
  }
7
7
  async canUse(input) {
8
8
  const policy = await this.#permissions.getEffectivePolicy(input.guildId, input.moduleId, input.permissionId);
9
- return canUseCommand(input.actor, policy);
9
+ const guildAccess = await this.#permissions.getGuildAccessPolicy?.(input.guildId);
10
+ return canUseCommand(input.actor, policy, guildAccess);
10
11
  }
11
12
  }
12
13
  //# sourceMappingURL=resource-authorization.js.map
@@ -169,6 +169,27 @@ export type ShardHealthSnapshot = z.infer<typeof shardHealthSnapshotSchema>;
169
169
  export declare const shardControlOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
170
170
  kind: z.ZodLiteral<"guild.state.get">;
171
171
  guildId: z.ZodString;
172
+ }, z.core.$strict>, z.ZodObject<{
173
+ kind: z.ZodLiteral<"guild.dashboard.access">;
174
+ guildId: z.ZodString;
175
+ actorUserId: z.ZodString;
176
+ }, z.core.$strict>, z.ZodObject<{
177
+ kind: z.ZodLiteral<"guild.access-policy.get">;
178
+ guildId: z.ZodString;
179
+ actorUserId: z.ZodString;
180
+ }, z.core.$strict>, z.ZodObject<{
181
+ actorUserId: z.ZodString;
182
+ correlationId: z.ZodString;
183
+ kind: z.ZodLiteral<"guild.access-policy.update">;
184
+ guildId: z.ZodString;
185
+ version: z.ZodNumber;
186
+ policy: z.ZodObject<{
187
+ administratorRoleIds: z.ZodArray<z.ZodString>;
188
+ moderatorRoleIds: z.ZodArray<z.ZodString>;
189
+ dashboardModeratorsEnabled: z.ZodBoolean;
190
+ dashboardAllowedRoleIds: z.ZodArray<z.ZodString>;
191
+ dashboardDeniedRoleIds: z.ZodArray<z.ZodString>;
192
+ }, z.core.$strict>;
172
193
  }, z.core.$strict>, z.ZodObject<{
173
194
  kind: z.ZodLiteral<"guild.module.public.read">;
174
195
  guildId: z.ZodString;
@@ -285,6 +306,8 @@ export declare const shardControlOperationSchema: z.ZodDiscriminatedUnion<[z.Zod
285
306
  relationId: z.ZodString;
286
307
  cursor: z.ZodOptional<z.ZodString>;
287
308
  limit: z.ZodNumber;
309
+ search: z.ZodOptional<z.ZodString>;
310
+ filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
288
311
  }, z.core.$strict>, z.ZodObject<{
289
312
  actorUserId: z.ZodString;
290
313
  correlationId: z.ZodString;
@@ -485,6 +508,27 @@ export declare const shardControlRequestSchema: z.ZodObject<{
485
508
  operation: z.ZodDiscriminatedUnion<[z.ZodObject<{
486
509
  kind: z.ZodLiteral<"guild.state.get">;
487
510
  guildId: z.ZodString;
511
+ }, z.core.$strict>, z.ZodObject<{
512
+ kind: z.ZodLiteral<"guild.dashboard.access">;
513
+ guildId: z.ZodString;
514
+ actorUserId: z.ZodString;
515
+ }, z.core.$strict>, z.ZodObject<{
516
+ kind: z.ZodLiteral<"guild.access-policy.get">;
517
+ guildId: z.ZodString;
518
+ actorUserId: z.ZodString;
519
+ }, z.core.$strict>, z.ZodObject<{
520
+ actorUserId: z.ZodString;
521
+ correlationId: z.ZodString;
522
+ kind: z.ZodLiteral<"guild.access-policy.update">;
523
+ guildId: z.ZodString;
524
+ version: z.ZodNumber;
525
+ policy: z.ZodObject<{
526
+ administratorRoleIds: z.ZodArray<z.ZodString>;
527
+ moderatorRoleIds: z.ZodArray<z.ZodString>;
528
+ dashboardModeratorsEnabled: z.ZodBoolean;
529
+ dashboardAllowedRoleIds: z.ZodArray<z.ZodString>;
530
+ dashboardDeniedRoleIds: z.ZodArray<z.ZodString>;
531
+ }, z.core.$strict>;
488
532
  }, z.core.$strict>, z.ZodObject<{
489
533
  kind: z.ZodLiteral<"guild.module.public.read">;
490
534
  guildId: z.ZodString;
@@ -601,6 +645,8 @@ export declare const shardControlRequestSchema: z.ZodObject<{
601
645
  relationId: z.ZodString;
602
646
  cursor: z.ZodOptional<z.ZodString>;
603
647
  limit: z.ZodNumber;
648
+ search: z.ZodOptional<z.ZodString>;
649
+ filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
604
650
  }, z.core.$strict>, z.ZodObject<{
605
651
  actorUserId: z.ZodString;
606
652
  correlationId: z.ZodString;
@@ -1306,6 +1352,27 @@ export declare const supervisorMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObje
1306
1352
  operation: z.ZodDiscriminatedUnion<[z.ZodObject<{
1307
1353
  kind: z.ZodLiteral<"guild.state.get">;
1308
1354
  guildId: z.ZodString;
1355
+ }, z.core.$strict>, z.ZodObject<{
1356
+ kind: z.ZodLiteral<"guild.dashboard.access">;
1357
+ guildId: z.ZodString;
1358
+ actorUserId: z.ZodString;
1359
+ }, z.core.$strict>, z.ZodObject<{
1360
+ kind: z.ZodLiteral<"guild.access-policy.get">;
1361
+ guildId: z.ZodString;
1362
+ actorUserId: z.ZodString;
1363
+ }, z.core.$strict>, z.ZodObject<{
1364
+ actorUserId: z.ZodString;
1365
+ correlationId: z.ZodString;
1366
+ kind: z.ZodLiteral<"guild.access-policy.update">;
1367
+ guildId: z.ZodString;
1368
+ version: z.ZodNumber;
1369
+ policy: z.ZodObject<{
1370
+ administratorRoleIds: z.ZodArray<z.ZodString>;
1371
+ moderatorRoleIds: z.ZodArray<z.ZodString>;
1372
+ dashboardModeratorsEnabled: z.ZodBoolean;
1373
+ dashboardAllowedRoleIds: z.ZodArray<z.ZodString>;
1374
+ dashboardDeniedRoleIds: z.ZodArray<z.ZodString>;
1375
+ }, z.core.$strict>;
1309
1376
  }, z.core.$strict>, z.ZodObject<{
1310
1377
  kind: z.ZodLiteral<"guild.module.public.read">;
1311
1378
  guildId: z.ZodString;
@@ -1422,6 +1489,8 @@ export declare const supervisorMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObje
1422
1489
  relationId: z.ZodString;
1423
1490
  cursor: z.ZodOptional<z.ZodString>;
1424
1491
  limit: z.ZodNumber;
1492
+ search: z.ZodOptional<z.ZodString>;
1493
+ filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
1425
1494
  }, z.core.$strict>, z.ZodObject<{
1426
1495
  actorUserId: z.ZodString;
1427
1496
  correlationId: z.ZodString;
@@ -46,6 +46,15 @@ const permissionState = z
46
46
  .max(100),
47
47
  })
48
48
  .strict();
49
+ const guildAccessPolicy = z
50
+ .object({
51
+ administratorRoleIds: z.array(snowflake).max(25),
52
+ moderatorRoleIds: z.array(snowflake).max(25),
53
+ dashboardModeratorsEnabled: z.boolean(),
54
+ dashboardAllowedRoleIds: z.array(snowflake).max(25),
55
+ dashboardDeniedRoleIds: z.array(snowflake).max(25),
56
+ })
57
+ .strict();
49
58
  const configurationValue = z.record(z.string().min(1).max(100), z.union([
50
59
  z.null(),
51
60
  z.string().max(4_000),
@@ -222,6 +231,29 @@ export const shardHealthSnapshotSchema = z
222
231
  });
223
232
  export const shardControlOperationSchema = z.discriminatedUnion("kind", [
224
233
  z.object({ kind: z.literal("guild.state.get"), guildId: snowflake }).strict(),
234
+ z
235
+ .object({
236
+ kind: z.literal("guild.dashboard.access"),
237
+ guildId: snowflake,
238
+ actorUserId: snowflake,
239
+ })
240
+ .strict(),
241
+ z
242
+ .object({
243
+ kind: z.literal("guild.access-policy.get"),
244
+ guildId: snowflake,
245
+ actorUserId: snowflake,
246
+ })
247
+ .strict(),
248
+ z
249
+ .object({
250
+ kind: z.literal("guild.access-policy.update"),
251
+ guildId: snowflake,
252
+ version: z.number().int().min(0),
253
+ policy: guildAccessPolicy,
254
+ ...mutationContext.shape,
255
+ })
256
+ .strict(),
225
257
  z
226
258
  .object({
227
259
  kind: z.literal("guild.module.public.read"),
@@ -330,6 +362,8 @@ export const shardControlOperationSchema = z.discriminatedUnion("kind", [
330
362
  relationId: resourceType,
331
363
  cursor: resourceCursor.optional(),
332
364
  limit: z.number().int().min(1).max(50),
365
+ search: z.string().trim().min(1).max(200).optional(),
366
+ filters: managedResourceFilters.optional(),
333
367
  ...mutationContext.shape,
334
368
  })
335
369
  .strict(),
@@ -911,6 +945,12 @@ export async function executeShardControlOperation(handler, operation) {
911
945
  });
912
946
  case "guild.state.get":
913
947
  return handler.getGuildState(operation.guildId);
948
+ case "guild.dashboard.access":
949
+ return handler.canAccessDashboard(operation);
950
+ case "guild.access-policy.get":
951
+ return handler.getGuildAccessPolicy(operation);
952
+ case "guild.access-policy.update":
953
+ return handler.updateGuildAccessPolicy(operation);
914
954
  case "guild.module.public.read":
915
955
  return handler.readPublicModuleProjection({
916
956
  guildId: operation.guildId,
@@ -1008,6 +1048,10 @@ export async function executeShardControlOperation(handler, operation) {
1008
1048
  resourceId: operation.resourceId,
1009
1049
  relationId: operation.relationId,
1010
1050
  ...(operation.cursor === undefined ? {} : { cursor: operation.cursor }),
1051
+ ...(operation.search === undefined ? {} : { search: operation.search }),
1052
+ ...(operation.filters === undefined
1053
+ ? {}
1054
+ : { filters: operation.filters }),
1011
1055
  limit: operation.limit,
1012
1056
  actorUserId: operation.actorUserId,
1013
1057
  correlationId: operation.correlationId,
@@ -16,6 +16,9 @@ export declare class ShardControlRouter implements BotControlHandler {
16
16
  notificationGuildId?: string;
17
17
  });
18
18
  getGuildState(guildId: string): ReturnType<BotControlHandler["getGuildState"]>;
19
+ canAccessDashboard(input: Parameters<BotControlHandler["canAccessDashboard"]>[0]): ReturnType<BotControlHandler["canAccessDashboard"]>;
20
+ getGuildAccessPolicy(input: Parameters<BotControlHandler["getGuildAccessPolicy"]>[0]): ReturnType<BotControlHandler["getGuildAccessPolicy"]>;
21
+ updateGuildAccessPolicy(input: Parameters<BotControlHandler["updateGuildAccessPolicy"]>[0]): ReturnType<BotControlHandler["updateGuildAccessPolicy"]>;
19
22
  readPublicModuleProjection(input: Parameters<BotControlHandler["readPublicModuleProjection"]>[0]): ReturnType<BotControlHandler["readPublicModuleProjection"]>;
20
23
  canAccessManagedResource(input: Parameters<BotControlHandler["canAccessManagedResource"]>[0]): ReturnType<BotControlHandler["canAccessManagedResource"]>;
21
24
  listGuildPresence(input: Parameters<BotControlHandler["listGuildPresence"]>[0]): ReturnType<BotControlHandler["listGuildPresence"]>;
@@ -1,7 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { BotControlTransportError, } from "./control-server.js";
3
3
  import { SHARD_CONTROL_TIMEOUT_MS, controlOperationGuildId, guildIdToShardId, shardControlOperationSchema, shardControlResponseSchema, } from "./shard-contracts.js";
4
- const LEASE_VALIDATION_TIMEOUT_MS = 1_000;
4
+ // Cross-region/public Redis connections used by local staging routinely take
5
+ // just under one second. Privacy operations validate the leader lease several
6
+ // times in succession (provider discovery plus execution), so a one-second
7
+ // deadline turned healthy leases into deterministic 503s. Keep the check
8
+ // bounded while allowing normal network variance.
9
+ const LEASE_VALIDATION_TIMEOUT_MS = 3_000;
5
10
  export class ShardControlRouter {
6
11
  input;
7
12
  constructor(input) {
@@ -10,6 +15,25 @@ export class ShardControlRouter {
10
15
  getGuildState(guildId) {
11
16
  return this.#route({ kind: "guild.state.get", guildId });
12
17
  }
18
+ canAccessDashboard(input) {
19
+ return this.#route({ kind: "guild.dashboard.access", ...input });
20
+ }
21
+ getGuildAccessPolicy(input) {
22
+ return this.#route({ kind: "guild.access-policy.get", ...input });
23
+ }
24
+ updateGuildAccessPolicy(input) {
25
+ return this.#route({
26
+ kind: "guild.access-policy.update",
27
+ ...input,
28
+ policy: {
29
+ administratorRoleIds: [...input.policy.administratorRoleIds],
30
+ moderatorRoleIds: [...input.policy.moderatorRoleIds],
31
+ dashboardModeratorsEnabled: input.policy.dashboardModeratorsEnabled,
32
+ dashboardAllowedRoleIds: [...input.policy.dashboardAllowedRoleIds],
33
+ dashboardDeniedRoleIds: [...input.policy.dashboardDeniedRoleIds],
34
+ },
35
+ });
36
+ }
13
37
  readPublicModuleProjection(input) {
14
38
  return this.#route({ kind: "guild.module.public.read", ...input });
15
39
  }
@@ -1,6 +1,6 @@
1
1
  import type { DatabasePool } from "@helyx/database";
2
2
  import type { CloudDiscordDirectory, CloudDiscordSnapshot } from "./cloud-connector.js";
3
- export declare const CLOUD_HELLO_GUILD_LIMIT = 10000;
3
+ export declare const CLOUD_HELLO_GUILD_LIMIT = 1000;
4
4
  export declare class SupervisorCloudDiscordDirectory implements CloudDiscordDirectory {
5
5
  #private;
6
6
  private readonly database;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -49,13 +49,13 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@helyx/config": ">=0.5.0 <0.6.0",
52
- "@helyx/control-protocol": ">=0.3.0 <0.4.0",
52
+ "@helyx/control-protocol": ">=0.4.0 <0.5.0",
53
53
  "@helyx/core": ">=0.1.0 <0.2.0",
54
- "@helyx/database": ">=0.7.10 <0.8.0",
54
+ "@helyx/database": ">=0.8.0 <0.9.0",
55
55
  "@helyx/framework": ">=0.1.0 <0.2.0",
56
56
  "@helyx/module-manifest": ">=0.6.0 <0.7.0",
57
- "@helyx/platform": ">=0.4.0 <0.5.0",
58
- "@helyx/sdk": ">=0.6.0 <0.7.0",
57
+ "@helyx/platform": ">=0.5.0 <0.6.0",
58
+ "@helyx/sdk": ">=0.7.0 <0.8.0",
59
59
  "@helyx/verification-contracts": ">=0.3.0 <0.4.0",
60
60
  "discord.js": "14.27.0",
61
61
  "fastify": "5.10.0",