@helyx/bot 0.4.26 → 0.4.28

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.28
4
+
5
+ ### Patch Changes
6
+
7
+ - Add the private testing-stage Forms module contracts and runtime integration,
8
+ and introduce server-configurable Helyx Administrator, Moderator, dashboard
9
+ access, and command-permission policies with fresh Discord authorization.
10
+
11
+ ## 0.4.27
12
+
13
+ ### Patch Changes
14
+
15
+ - Allow a strictly newer coordinated deployment to atomically replace an incompatible module-catalogue generation while preserving application and environment namespace isolation.
16
+
3
17
  ## 0.4.26
4
18
 
5
19
  ### 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,7 +1,7 @@
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";
@@ -41,6 +41,15 @@ const permissionState = z.object({
41
41
  }))
42
42
  .max(100),
43
43
  });
44
+ const guildAccessPolicy = z
45
+ .object({
46
+ administratorRoleIds: z.array(snowflake).max(25),
47
+ moderatorRoleIds: z.array(snowflake).max(25),
48
+ dashboardModeratorsEnabled: z.boolean(),
49
+ dashboardAllowedRoleIds: z.array(snowflake).max(25),
50
+ dashboardDeniedRoleIds: z.array(snowflake).max(25),
51
+ })
52
+ .strict();
44
53
  const mutationContext = z.object({
45
54
  actorUserId: snowflake,
46
55
  correlationId: z.uuid(),
@@ -146,6 +155,7 @@ export class DiscordGuildResourceDirectory {
146
155
  id: role.id,
147
156
  name: role.name,
148
157
  colour: role.hexColor,
158
+ position: role.position,
149
159
  })),
150
160
  };
151
161
  }
@@ -181,7 +191,13 @@ export class DiscordGuildResourceDirectory {
181
191
  async resolveActor(guildId, actorUserId) {
182
192
  try {
183
193
  const guild = await this.client.guilds.fetch(guildId);
184
- const member = await guild.members.fetch(actorUserId);
194
+ // Dashboard authorization must reflect current Discord roles and
195
+ // permissions even when a member-update gateway event was missed.
196
+ const member = await guild.members.fetch({
197
+ user: actorUserId,
198
+ force: true,
199
+ cache: true,
200
+ });
185
201
  return {
186
202
  userId: member.id,
187
203
  roleIds: [...member.roles.cache.keys()],
@@ -190,6 +206,7 @@ export class DiscordGuildResourceDirectory {
190
206
  .map(normalizeDiscordPermission),
191
207
  isServerOwner: guild.ownerId === member.id,
192
208
  isAdministrator: member.permissions.has(PermissionFlagsBits.Administrator),
209
+ highestRolePosition: member.roles.highest.position,
193
210
  };
194
211
  }
195
212
  catch (error) {
@@ -425,6 +442,88 @@ export class BotControlService {
425
442
  logging,
426
443
  };
427
444
  }
445
+ async canAccessDashboard(input) {
446
+ const actor = await this.resources.resolveActor(input.guildId, input.actorUserId);
447
+ return canAccessGuildDashboard(actor, await this.#readGuildAccessPolicy(input.guildId));
448
+ }
449
+ async getGuildAccessPolicy(input) {
450
+ const actor = await this.resources.resolveActor(input.guildId, input.actorUserId);
451
+ const record = await new GuildAccessPolicyRepository(this.database).get(input.guildId);
452
+ const policy = record ?? DEFAULT_GUILD_ACCESS_POLICY;
453
+ return {
454
+ administratorRoleIds: [...policy.administratorRoleIds],
455
+ moderatorRoleIds: [...policy.moderatorRoleIds],
456
+ dashboardModeratorsEnabled: policy.dashboardModeratorsEnabled,
457
+ dashboardAllowedRoleIds: [...policy.dashboardAllowedRoleIds],
458
+ dashboardDeniedRoleIds: [...policy.dashboardDeniedRoleIds],
459
+ version: record ? safeVersion(record.version) : 0,
460
+ canManage: actor.isServerOwner || actor.isAdministrator,
461
+ actorAuthority: resolveHelyxAuthority(actor, policy),
462
+ actorRoleIds: [...actor.roleIds],
463
+ actorHighestRolePosition: actor.highestRolePosition ?? null,
464
+ updatedAt: record?.updatedAt.toISOString() ?? null,
465
+ };
466
+ }
467
+ async updateGuildAccessPolicy(input) {
468
+ const actor = await this.resources.resolveActor(input.guildId, input.actorUserId);
469
+ if (!actor.isServerOwner && !actor.isAdministrator)
470
+ throw new BotControlForbiddenError("Only the server owner or a Discord Administrator can change dashboard access");
471
+ const resources = await this.resources.getGuildResources(input.guildId);
472
+ const availableRoles = new Set(resources.roles.map(({ id }) => id));
473
+ const roleIds = [
474
+ ...input.policy.administratorRoleIds,
475
+ ...input.policy.moderatorRoleIds,
476
+ ...input.policy.dashboardAllowedRoleIds,
477
+ ...input.policy.dashboardDeniedRoleIds,
478
+ ];
479
+ if (roleIds.some((roleId) => !availableRoles.has(roleId)))
480
+ throw new BotControlValidationError("Dashboard access contains a Discord role that is no longer available");
481
+ const saved = await withTransaction(this.database, async (connection) => {
482
+ const record = await new GuildAccessPolicyRepository(connection).save({
483
+ guildId: input.guildId,
484
+ administratorRoleIds: [...input.policy.administratorRoleIds],
485
+ moderatorRoleIds: [...input.policy.moderatorRoleIds],
486
+ dashboardModeratorsEnabled: input.policy.dashboardModeratorsEnabled,
487
+ dashboardAllowedRoleIds: [...input.policy.dashboardAllowedRoleIds],
488
+ dashboardDeniedRoleIds: [...input.policy.dashboardDeniedRoleIds],
489
+ expectedVersion: input.version === 0 ? null : BigInt(input.version),
490
+ updatedBy: input.actorUserId,
491
+ });
492
+ await new AuditRepository(connection).append({
493
+ guildId: input.guildId,
494
+ actorUserId: input.actorUserId,
495
+ action: "guild.access.updated",
496
+ source: "dashboard",
497
+ correlationId: input.correlationId,
498
+ afterState: {
499
+ version: record.version.toString(),
500
+ administratorRoleCount: record.administratorRoleIds.length,
501
+ moderatorRoleCount: record.moderatorRoleIds.length,
502
+ dashboardAllowedRoleCount: record.dashboardAllowedRoleIds.length,
503
+ dashboardDeniedRoleCount: record.dashboardDeniedRoleIds.length,
504
+ dashboardModeratorsEnabled: record.dashboardModeratorsEnabled,
505
+ },
506
+ });
507
+ return record;
508
+ });
509
+ return {
510
+ administratorRoleIds: [...saved.administratorRoleIds],
511
+ moderatorRoleIds: [...saved.moderatorRoleIds],
512
+ dashboardModeratorsEnabled: saved.dashboardModeratorsEnabled,
513
+ dashboardAllowedRoleIds: [...saved.dashboardAllowedRoleIds],
514
+ dashboardDeniedRoleIds: [...saved.dashboardDeniedRoleIds],
515
+ version: safeVersion(saved.version),
516
+ canManage: true,
517
+ actorAuthority: resolveHelyxAuthority(actor, saved),
518
+ actorRoleIds: [...actor.roleIds],
519
+ actorHighestRolePosition: actor.highestRolePosition ?? null,
520
+ updatedAt: saved.updatedAt.toISOString(),
521
+ };
522
+ }
523
+ async #readGuildAccessPolicy(guildId) {
524
+ return ((await new GuildAccessPolicyRepository(this.database).get(guildId)) ??
525
+ DEFAULT_GUILD_ACCESS_POLICY);
526
+ }
428
527
  async readPublicModuleProjection(input) {
429
528
  const module = this.#requireModule(input.moduleId);
430
529
  if (!module.publicProjection)
@@ -583,6 +682,26 @@ export class BotControlService {
583
682
  ])));
584
683
  for (const command of commands)
585
684
  assertVersion(currentCommands.get(command.id).version, supplied.get(command.id).version);
685
+ const [actor, guildAccess, resources] = await Promise.all([
686
+ this.resources.resolveActor(input.guildId, input.actorUserId),
687
+ this.#permissions.getGuildAccessPolicy?.(input.guildId) ??
688
+ Promise.resolve(DEFAULT_GUILD_ACCESS_POLICY),
689
+ this.resources.getGuildResources(input.guildId),
690
+ ]);
691
+ const rolePositions = Object.fromEntries(resources.roles.map((role) => [role.id, role.position]));
692
+ const requestedModulePolicy = fromDashboardPolicy(input.state.module, currentModule.policy);
693
+ if (!permissionPoliciesEqual(currentModule.policy, requestedModulePolicy))
694
+ assertPermissionMutationAllowed(canMutatePermissionPolicy(actor, currentModule.policy, requestedModulePolicy, guildAccess, rolePositions));
695
+ for (const command of commands) {
696
+ const requested = supplied.get(command.id);
697
+ const current = currentCommands.get(command.id);
698
+ const target = requested.enabled
699
+ ? fromDashboardPolicy(requested.policy, current.policy)
700
+ : requestedModulePolicy;
701
+ const enabledChanged = requested.enabled === current.inherited;
702
+ if (enabledChanged || !permissionPoliciesEqual(current.policy, target))
703
+ assertPermissionMutationAllowed(canMutatePermissionPolicy(actor, current.policy, target, guildAccess, rolePositions));
704
+ }
586
705
  await withTransaction(this.database, async (connection) => {
587
706
  const policies = new PermissionPolicyRepository(connection);
588
707
  const audit = new AuditRepository(connection);
@@ -765,6 +884,8 @@ export class BotControlService {
765
884
  return normalizeManagedResourceRelationPage(managedResourceRelationPageSchema.parse(await resource.listRelation(context, input.resourceId, input.relationId, {
766
885
  ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
767
886
  limit: input.limit,
887
+ ...(input.search === undefined ? {} : { search: input.search }),
888
+ ...(input.filters === undefined ? {} : { filters: input.filters }),
768
889
  })), relation.columns.map(({ key }) => key));
769
890
  }
770
891
  async previewManagedResource(input) {
@@ -877,7 +998,8 @@ export class BotControlService {
877
998
  async #authorizeAction(guildId, module, actorUserId, permissionId) {
878
999
  const actor = await this.resources.resolveActor(guildId, actorUserId);
879
1000
  const policy = await this.#permissions.getEffectivePolicy(guildId, module.manifest.id, permissionId);
880
- if (!canUseCommand(actor, policy))
1001
+ const guildAccess = await this.#permissions.getGuildAccessPolicy?.(guildId);
1002
+ if (!canUseCommand(actor, policy, guildAccess))
881
1003
  throw new BotControlForbiddenError();
882
1004
  return actor;
883
1005
  }
@@ -1139,6 +1261,35 @@ export async function createBotControlServer(input) {
1139
1261
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1140
1262
  return input.service.getGuildState(guildId);
1141
1263
  });
1264
+ server.post("/v1/guilds/:guildId/dashboard-access", async (request) => {
1265
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1266
+ const { actorUserId } = z
1267
+ .object({ actorUserId: snowflake })
1268
+ .strict()
1269
+ .parse(request.body);
1270
+ return {
1271
+ allowed: await input.service.canAccessDashboard({
1272
+ guildId,
1273
+ actorUserId,
1274
+ }),
1275
+ };
1276
+ });
1277
+ server.get("/v1/guilds/:guildId/access-policy", async (request) => {
1278
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1279
+ const { actorUserId } = z
1280
+ .object({ actorUserId: snowflake })
1281
+ .strict()
1282
+ .parse(request.query);
1283
+ return input.service.getGuildAccessPolicy({ guildId, actorUserId });
1284
+ });
1285
+ server.put("/v1/guilds/:guildId/access-policy", async (request) => {
1286
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1287
+ const body = mutationContext
1288
+ .extend({ version: z.number().int().min(0), policy: guildAccessPolicy })
1289
+ .strict()
1290
+ .parse(request.body);
1291
+ return input.service.updateGuildAccessPolicy({ guildId, ...body });
1292
+ });
1142
1293
  server.get("/v1/guilds/:guildId/modules/:moduleId/public-projection", async (request) => {
1143
1294
  const params = z
1144
1295
  .object({ guildId: snowflake, moduleId })
@@ -1272,6 +1423,21 @@ export async function createBotControlServer(input) {
1272
1423
  .object({
1273
1424
  cursor: resourceCursor.optional(),
1274
1425
  limit: z.coerce.number().int().min(1).max(50).default(50),
1426
+ search: z.string().trim().min(1).max(200).optional(),
1427
+ filters: z
1428
+ .preprocess((value) => {
1429
+ if (typeof value !== "string")
1430
+ return value;
1431
+ if (value.length > 2_048)
1432
+ return value;
1433
+ try {
1434
+ return JSON.parse(value);
1435
+ }
1436
+ catch {
1437
+ return value;
1438
+ }
1439
+ }, managedResourceFilters)
1440
+ .optional(),
1275
1441
  actorUserId: snowflake,
1276
1442
  correlationId: z.uuid(),
1277
1443
  })
@@ -1281,6 +1447,8 @@ export async function createBotControlServer(input) {
1281
1447
  ...params,
1282
1448
  ...(query.cursor === undefined ? {} : { cursor: query.cursor }),
1283
1449
  limit: query.limit,
1450
+ ...(query.search === undefined ? {} : { search: query.search }),
1451
+ ...(query.filters === undefined ? {} : { filters: query.filters }),
1284
1452
  actorUserId: query.actorUserId,
1285
1453
  correlationId: query.correlationId,
1286
1454
  });
@@ -1668,6 +1836,9 @@ function mapDashboardModule(module) {
1668
1836
  actions: (dashboard.actions ?? []).map((action) => ({ ...action })),
1669
1837
  achievementTriggers: (module.manifest.contributions?.achievementTriggers ?? []).map((trigger) => structuredClone(trigger)),
1670
1838
  resources: (dashboard.resources ?? []).map((resource) => structuredClone(resource)),
1839
+ workspace: dashboard.workspace
1840
+ ? structuredClone(dashboard.workspace)
1841
+ : undefined,
1671
1842
  };
1672
1843
  }
1673
1844
  function dashboardCommands(module) {
@@ -1702,14 +1873,18 @@ function dashboardCommands(module) {
1702
1873
  function toDashboardPolicy(policy) {
1703
1874
  const preset = policy.everyone
1704
1875
  ? "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";
1876
+ : policy.accessLevel === "administrator"
1877
+ ? "administrators"
1878
+ : policy.accessLevel === "moderator"
1879
+ ? "moderators"
1880
+ : policy.allowedRoleIds.length > 0 &&
1881
+ policy.requiredPermissions.length === 0
1882
+ ? "custom"
1883
+ : policy.requiredPermissions.includes("ADMINISTRATOR")
1884
+ ? "administrators"
1885
+ : policy.requiredPermissions.includes("MANAGE_GUILD")
1886
+ ? "moderators"
1887
+ : "custom";
1713
1888
  return {
1714
1889
  preset,
1715
1890
  allowedRoleIds: [...policy.allowedRoleIds],
@@ -1719,11 +1894,12 @@ function toDashboardPolicy(policy) {
1719
1894
  function fromDashboardPolicy(input, current) {
1720
1895
  return {
1721
1896
  everyone: input.preset === "everyone",
1722
- requiredPermissions: input.preset === "administrators"
1723
- ? ["MANAGE_GUILD"]
1897
+ ...(input.preset === "administrators"
1898
+ ? { accessLevel: "administrator" }
1724
1899
  : input.preset === "moderators"
1725
- ? ["MANAGE_MESSAGES"]
1726
- : [],
1900
+ ? { accessLevel: "moderator" }
1901
+ : {}),
1902
+ requiredPermissions: [],
1727
1903
  allowedRoleIds: input.preset === "custom" ? input.allowedRoleIds : [],
1728
1904
  deniedRoleIds: input.deniedRoleIds,
1729
1905
  allowedUserIds: [...current.allowedUserIds],
@@ -1740,6 +1916,24 @@ function emptyPolicy() {
1740
1916
  deniedUserIds: [],
1741
1917
  };
1742
1918
  }
1919
+ function permissionPoliciesEqual(left, right) {
1920
+ return (left.everyone === right.everyone &&
1921
+ left.accessLevel === right.accessLevel &&
1922
+ sameStrings(left.requiredPermissions, right.requiredPermissions) &&
1923
+ sameStrings(left.allowedRoleIds, right.allowedRoleIds) &&
1924
+ sameStrings(left.deniedRoleIds, right.deniedRoleIds) &&
1925
+ sameStrings(left.allowedUserIds, right.allowedUserIds) &&
1926
+ sameStrings(left.deniedUserIds, right.deniedUserIds));
1927
+ }
1928
+ function sameStrings(left, right) {
1929
+ return (left.length === right.length &&
1930
+ left.every((value, index) => value === right[index]));
1931
+ }
1932
+ function assertPermissionMutationAllowed(decision) {
1933
+ if (decision.allowed)
1934
+ return;
1935
+ throw new BotControlForbiddenError(permissionMutationDenialMessage(decision.reason));
1936
+ }
1743
1937
  function safeVersion(version) {
1744
1938
  const value = Number(version);
1745
1939
  if (!Number.isSafeInteger(value) || value < 1)
@@ -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,
@@ -137,8 +137,22 @@ export class RedisShardCoordinator {
137
137
  const parsed = activeGenerationSchema.safeParse(parseJson(current ?? ""));
138
138
  if (parsed.success) {
139
139
  if (JSON.stringify(withoutDeploymentOrder(parsed.data)) !==
140
- JSON.stringify(accepted))
141
- throw new Error("Shard coordination namespace is owned by an incompatible generation");
140
+ JSON.stringify(accepted)) {
141
+ if (parsed.data.applicationId !== accepted.applicationId ||
142
+ parsed.data.environment !== accepted.environment ||
143
+ deploymentOrder <= parsed.data.deploymentOrder ||
144
+ !current)
145
+ throw new Error("Shard coordination namespace is owned by an incompatible generation");
146
+ const replaced = await compareAndReplace(this.#command, this.#generationKey(), current, serialized, GENERATION_TTL_MS);
147
+ if (replaced)
148
+ return deploymentOrder;
149
+ const winner = activeGenerationSchema.safeParse(parseJson((await this.#command.get(this.#generationKey())) ?? ""));
150
+ if (winner.success &&
151
+ JSON.stringify(withoutDeploymentOrder(winner.data)) ===
152
+ JSON.stringify(accepted))
153
+ return winner.data.deploymentOrder;
154
+ throw new Error("Shard coordination generation changed while it was being validated");
155
+ }
142
156
  if (!current ||
143
157
  !(await compareAndExpire(this.#command, this.#generationKey(), current, GENERATION_TTL_MS)))
144
158
  throw new Error("Shard coordination generation changed while it was being validated");
@@ -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"]>;
@@ -10,6 +10,25 @@ export class ShardControlRouter {
10
10
  getGuildState(guildId) {
11
11
  return this.#route({ kind: "guild.state.get", guildId });
12
12
  }
13
+ canAccessDashboard(input) {
14
+ return this.#route({ kind: "guild.dashboard.access", ...input });
15
+ }
16
+ getGuildAccessPolicy(input) {
17
+ return this.#route({ kind: "guild.access-policy.get", ...input });
18
+ }
19
+ updateGuildAccessPolicy(input) {
20
+ return this.#route({
21
+ kind: "guild.access-policy.update",
22
+ ...input,
23
+ policy: {
24
+ administratorRoleIds: [...input.policy.administratorRoleIds],
25
+ moderatorRoleIds: [...input.policy.moderatorRoleIds],
26
+ dashboardModeratorsEnabled: input.policy.dashboardModeratorsEnabled,
27
+ dashboardAllowedRoleIds: [...input.policy.dashboardAllowedRoleIds],
28
+ dashboardDeniedRoleIds: [...input.policy.dashboardDeniedRoleIds],
29
+ },
30
+ });
31
+ }
13
32
  readPublicModuleProjection(input) {
14
33
  return this.#route({ kind: "guild.module.public.read", ...input });
15
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",