@helyx/bot 1.5.1 → 1.5.3

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/command-root-catalogue.json +452 -0
  3. package/dist/cached-message-deletion-authority.d.ts +27 -0
  4. package/dist/cached-message-deletion-authority.js +288 -0
  5. package/dist/channel-audit-configuration.d.ts +11 -1
  6. package/dist/channel-audit-configuration.js +28 -6
  7. package/dist/channel-audit-control.js +42 -2
  8. package/dist/channel-audit-permissions.js +76 -3
  9. package/dist/cloud-command-policy-sync.d.ts +26 -0
  10. package/dist/cloud-command-policy-sync.js +198 -0
  11. package/dist/cloud-connector.d.ts +6 -1
  12. package/dist/cloud-connector.js +224 -53
  13. package/dist/cloud-control-dispatcher.js +18 -0
  14. package/dist/command-policy-runtime.d.ts +23 -0
  15. package/dist/command-policy-runtime.js +53 -0
  16. package/dist/command-root-catalogue.d.ts +5 -0
  17. package/dist/command-root-catalogue.js +16 -0
  18. package/dist/control-error-mapping.d.ts +8 -0
  19. package/dist/control-error-mapping.js +60 -0
  20. package/dist/control-errors.d.ts +3 -1
  21. package/dist/control-errors.js +4 -1
  22. package/dist/control-health-routes.d.ts +10 -0
  23. package/dist/control-health-routes.js +28 -0
  24. package/dist/control-server-schemas.d.ts +7 -0
  25. package/dist/control-server-schemas.js +9 -0
  26. package/dist/control-server.d.ts +27 -6
  27. package/dist/control-server.js +103 -122
  28. package/dist/creator-invite-key.d.ts +6 -0
  29. package/dist/creator-invite-key.js +16 -0
  30. package/dist/creator-invite-service.d.ts +3 -1
  31. package/dist/creator-invite-service.js +8 -7
  32. package/dist/creator-onboarding-platform.d.ts +1 -0
  33. package/dist/creator-onboarding-platform.js +5 -2
  34. package/dist/dashboard-feature-control.d.ts +2 -1
  35. package/dist/dashboard-feature-control.js +1 -0
  36. package/dist/dashboard-logging.d.ts +20 -0
  37. package/dist/dashboard-logging.js +39 -0
  38. package/dist/dashboard-module-projection.d.ts +1 -1
  39. package/dist/dashboard-module-projection.js +5 -2
  40. package/dist/dashboard-preview-import-routes.js +8 -0
  41. package/dist/database-bootstrap.d.ts +1 -1
  42. package/dist/database-bootstrap.js +4 -3
  43. package/dist/discord-channel-audit.js +1 -0
  44. package/dist/discord-user-identities.d.ts +14 -0
  45. package/dist/discord-user-identities.js +64 -0
  46. package/dist/emergency-deletion-dispatcher.d.ts +68 -0
  47. package/dist/emergency-deletion-dispatcher.js +502 -0
  48. package/dist/emergency-deletion-rest-transport.d.ts +24 -0
  49. package/dist/emergency-deletion-rest-transport.js +253 -0
  50. package/dist/events.d.ts +5 -0
  51. package/dist/events.js +53 -10
  52. package/dist/guarded-discord-dispatcher.d.ts +29 -0
  53. package/dist/guarded-discord-dispatcher.js +204 -0
  54. package/dist/guild-command-discord-client.d.ts +2 -0
  55. package/dist/guild-command-discord-client.js +7 -0
  56. package/dist/guild-command-projection.d.ts +4 -1
  57. package/dist/guild-command-projection.js +25 -0
  58. package/dist/guild-command-read-service.d.ts +10 -1
  59. package/dist/guild-command-read-service.js +37 -0
  60. package/dist/guild-command-worker.d.ts +19 -4
  61. package/dist/guild-command-worker.js +356 -26
  62. package/dist/guild-message-protection-author-roles.d.ts +7 -0
  63. package/dist/guild-message-protection-author-roles.js +93 -0
  64. package/dist/guild-message-protection-deletion.d.ts +21 -0
  65. package/dist/guild-message-protection-deletion.js +138 -0
  66. package/dist/guild-message-protection-delivery.d.ts +41 -0
  67. package/dist/guild-message-protection-delivery.js +223 -0
  68. package/dist/guild-message-update-metadata.d.ts +32 -0
  69. package/dist/guild-message-update-metadata.js +255 -0
  70. package/dist/guilds.d.ts +2 -1
  71. package/dist/guilds.js +11 -1
  72. package/dist/hosted-server-moderation.d.ts +2 -2
  73. package/dist/index.js +68 -4
  74. package/dist/interactions.d.ts +3 -1
  75. package/dist/interactions.js +77 -12
  76. package/dist/invite-rollup-maintenance.d.ts +9 -0
  77. package/dist/invite-rollup-maintenance.js +12 -0
  78. package/dist/managed-resource-authorization.d.ts +15 -0
  79. package/dist/managed-resource-authorization.js +37 -0
  80. package/dist/managed-resource-create-defaults.d.ts +21 -0
  81. package/dist/managed-resource-create-defaults.js +83 -0
  82. package/dist/managed-resource-query.d.ts +6 -0
  83. package/dist/managed-resource-query.js +15 -0
  84. package/dist/member-role-ids.d.ts +17 -0
  85. package/dist/member-role-ids.js +66 -0
  86. package/dist/message-protection-admission.d.ts +60 -0
  87. package/dist/message-protection-admission.js +279 -0
  88. package/dist/message-protection-cold-data.d.ts +42 -0
  89. package/dist/message-protection-cold-data.js +180 -0
  90. package/dist/message-protection-coordinator.d.ts +73 -0
  91. package/dist/message-protection-coordinator.js +622 -0
  92. package/dist/message-protection-settings.d.ts +40 -0
  93. package/dist/message-protection-settings.js +275 -0
  94. package/dist/message-protection-summary.d.ts +8 -0
  95. package/dist/message-protection-summary.js +98 -0
  96. package/dist/module-availability-control.js +20 -12
  97. package/dist/module-logging.d.ts +3 -2
  98. package/dist/module-logging.js +6 -1
  99. package/dist/modules.js +56 -1
  100. package/dist/mysql-runtime-persistence.d.ts +1 -0
  101. package/dist/mysql-runtime-persistence.js +4 -1
  102. package/dist/nexus-command-recovery-control-routes.d.ts +5 -0
  103. package/dist/nexus-command-recovery-control-routes.js +48 -0
  104. package/dist/nexus-command-references.d.ts +6 -0
  105. package/dist/nexus-command-references.js +33 -0
  106. package/dist/scheduled-task-runner.js +23 -4
  107. package/dist/shard-command-startup.d.ts +13 -0
  108. package/dist/shard-command-startup.js +27 -0
  109. package/dist/shard-contracts.d.ts +84 -0
  110. package/dist/shard-contracts.js +33 -0
  111. package/dist/shard-core-readiness.d.ts +5 -0
  112. package/dist/shard-core-readiness.js +19 -0
  113. package/dist/shard-router.d.ts +3 -0
  114. package/dist/shard-router.js +10 -1
  115. package/dist/shard-supervisor.d.ts +1 -1
  116. package/dist/shard-supervisor.js +131 -43
  117. package/dist/shard-worker.d.ts +1 -0
  118. package/dist/shard-worker.js +37 -15
  119. package/documentation/README.md +1 -0
  120. package/documentation/guides/self-hosting.md +54 -0
  121. package/package.json +18 -17
@@ -12,6 +12,10 @@ export class GuildCommandWorker {
12
12
  #installations;
13
13
  #availability;
14
14
  #modules;
15
+ #commandRestrictions;
16
+ #commandCatalogue;
17
+ #commandPolicyDelivery;
18
+ #catalogueReady = false;
15
19
  #logger;
16
20
  #workerId;
17
21
  #fencingToken;
@@ -41,6 +45,9 @@ export class GuildCommandWorker {
41
45
  this.#installations = repositories.installations;
42
46
  this.#availability = repositories.availability;
43
47
  this.#modules = input.modules;
48
+ this.#commandRestrictions = input.commandRestrictions;
49
+ this.#commandCatalogue = input.commandCatalogue;
50
+ this.#commandPolicyDelivery = input.commandPolicyDelivery;
44
51
  this.#logger = input.logger;
45
52
  this.#workerId = input.workerId ?? `guild-command:${randomUUID()}`;
46
53
  this.#fencingToken = input.fencingToken;
@@ -62,6 +69,16 @@ export class GuildCommandWorker {
62
69
  async runOnce() {
63
70
  if (this.#stopping)
64
71
  return;
72
+ await this.#commandRestrictions?.recheck();
73
+ try {
74
+ await this.#commandCatalogue?.recheck();
75
+ this.#catalogueReady = this.#commandCatalogue !== undefined;
76
+ }
77
+ catch {
78
+ this.#catalogueReady = false;
79
+ this.#logger.warn({ event: "guild_commands.catalogue_unavailable" }, "Command catalogue recovery is unavailable");
80
+ return;
81
+ }
65
82
  const capacity = Math.max(0, this.#concurrency - this.#active.size);
66
83
  if (capacity === 0)
67
84
  return;
@@ -154,12 +171,15 @@ export class GuildCommandWorker {
154
171
  const heartbeat = setInterval(renew, Math.max(1_000, Math.floor(this.#leaseMs / 3)));
155
172
  heartbeat.unref();
156
173
  try {
157
- const [enabled, availability, state] = await Promise.all([
174
+ const [enabled, availability, state, rootMappings] = await Promise.all([
158
175
  this.#installations.listEnabledModules(request.guildId),
159
176
  this.#availability.list(),
160
177
  this.#repository.getState(request.applicationId, request.guildId),
178
+ this.#repository.listRootMappings(request.applicationId, request.guildId),
161
179
  ]);
162
- if (!state || state.desiredRevision !== request.requestedRevision) {
180
+ if (!state ||
181
+ state.desiredRevision !== request.requestedRevision ||
182
+ rootMappings.some(({ desiredRevision }) => desiredRevision > request.requestedRevision)) {
163
183
  await this.#repository.completeDryRun({
164
184
  request,
165
185
  workerId: this.#workerId,
@@ -174,6 +194,35 @@ export class GuildCommandWorker {
174
194
  });
175
195
  return;
176
196
  }
197
+ const currentPolicy = await this.#commandRestrictions?.getPolicy();
198
+ for (const mapping of rootMappings) {
199
+ if (mapping.canonicalRoot !== "nexus")
200
+ throw new GuildCommandLiveError("unsupported_root_mapping");
201
+ if (mapping.desiredRoot === "nexus")
202
+ continue;
203
+ const policy = currentPolicy ??
204
+ this.#modules.find(({ manifest }) => manifest.id === "helyx.nexus-core")?.nexusCommandNamePolicy;
205
+ const result = policy?.evaluate({
206
+ guildId: request.guildId,
207
+ proposedName: mapping.desiredRoot,
208
+ });
209
+ if (!policy?.version ||
210
+ !result ||
211
+ result.state === "unconfigured" ||
212
+ result.policyVersion !== policy.version)
213
+ throw new GuildCommandLiveError("command_name_policy_unavailable");
214
+ if (result.state === "blocked")
215
+ throw new GuildCommandLiveError("command_name_blocked");
216
+ }
217
+ let reservedCommandNames = [];
218
+ const catalogueReady = this.#catalogueReady;
219
+ try {
220
+ reservedCommandNames =
221
+ (await this.#commandCatalogue?.getReservedCommandNames()) ?? [];
222
+ }
223
+ catch {
224
+ throw new GuildCommandLiveError("command_catalogue_unavailable");
225
+ }
177
226
  const projection = projectGuildCommands({
178
227
  modules: this.#modules,
179
228
  enabledModuleIds: new Set(enabled.map(({ moduleId }) => moduleId)),
@@ -182,7 +231,16 @@ export class GuildCommandWorker {
182
231
  .map(({ moduleId }) => moduleId)),
183
232
  profile: state.visibilityProfile,
184
233
  includeInternalCommands: request.guildId === this.#internalGuildId,
234
+ rootMappings,
235
+ reservedCommandNames,
185
236
  });
237
+ await this.#validatePolicyDelivery(projection, currentPolicy?.version ??
238
+ this.#modules.find(({ manifest }) => manifest.id === "helyx.nexus-core")?.nexusCommandNamePolicy?.version ??
239
+ undefined);
240
+ if (!catalogueReady &&
241
+ rootMappings.some(({ desiredRoot }) => desiredRoot !== "nexus") &&
242
+ projection.definitions.some(({ ownerModuleId }) => ownerModuleId === "helyx.nexus-core"))
243
+ throw new GuildCommandLiveError("command_catalogue_unavailable");
186
244
  if (this.#discord?.canWrite(request.guildId) &&
187
245
  leaseAlive &&
188
246
  !this.#controller.signal.aborted) {
@@ -216,7 +274,8 @@ export class GuildCommandWorker {
216
274
  if (safeCode === "discord_observation_failed" ||
217
275
  safeCode === "discord_rate_limited" ||
218
276
  safeCode === "discord_write_ambiguous" ||
219
- safeCode === "discord_write_mismatch")
277
+ safeCode === "discord_write_mismatch" ||
278
+ safeCode === "command_policy_unverified")
220
279
  await this.#repository.defer({
221
280
  request,
222
281
  workerId: this.#workerId,
@@ -276,6 +335,20 @@ export class GuildCommandWorker {
276
335
  leaseMs: this.#leaseMs,
277
336
  verificationDelayMs: 1_000,
278
337
  signal: this.#controller.signal,
338
+ validateWrite: async () => {
339
+ let reservedCommandNames = [];
340
+ if (this.#commandCatalogue) {
341
+ try {
342
+ reservedCommandNames =
343
+ await this.#commandCatalogue.getReservedCommandNames();
344
+ }
345
+ catch {
346
+ throw new GuildCommandLiveError("command_catalogue_unavailable");
347
+ }
348
+ }
349
+ const policyVersion = await this.#validateCurrentNexusProjection(projection, reservedCommandNames, request.guildId);
350
+ await this.#validatePolicyDelivery(projection, policyVersion);
351
+ },
279
352
  });
280
353
  if (!result)
281
354
  return;
@@ -291,6 +364,44 @@ export class GuildCommandWorker {
291
364
  outcome: result.outcome,
292
365
  }, "Guild commands reconciled");
293
366
  }
367
+ async #validatePolicyDelivery(projection, policyVersion) {
368
+ if (!this.#commandPolicyDelivery ||
369
+ !projection.definitions.some(({ ownerModuleId, name }) => ownerModuleId === "helyx.nexus-core" && name !== "nexus"))
370
+ return;
371
+ try {
372
+ const gate = await this.#commandPolicyDelivery.readGate();
373
+ if (gate.ready &&
374
+ policyVersion === `command-restrictions:${String(gate.revision)}`)
375
+ return;
376
+ }
377
+ catch {
378
+ /* A read failure cannot authorize an unverified custom root. */
379
+ }
380
+ throw new GuildCommandLiveError("command_policy_unverified");
381
+ }
382
+ async #validateCurrentNexusProjection(projection, reservedCommandNames, guildId) {
383
+ const names = projection.definitions
384
+ .filter(({ ownerModuleId, name }) => ownerModuleId === "helyx.nexus-core" && name !== "nexus")
385
+ .map(({ name }) => name);
386
+ if (!names.length)
387
+ return undefined;
388
+ const policy = (await this.#commandRestrictions?.getPolicy()) ??
389
+ this.#modules.find(({ manifest }) => manifest.id === "helyx.nexus-core")
390
+ ?.nexusCommandNamePolicy;
391
+ for (const name of names) {
392
+ if (reservedCommandNames.includes(name))
393
+ throw new GuildCommandLiveError("command_name_reserved");
394
+ const result = policy?.evaluate({ guildId, proposedName: name });
395
+ if (!policy?.version ||
396
+ !result ||
397
+ result.state === "unconfigured" ||
398
+ result.policyVersion !== policy.version)
399
+ throw new GuildCommandLiveError("command_name_policy_unavailable");
400
+ if (result.state === "blocked")
401
+ throw new GuildCommandLiveError("command_name_blocked");
402
+ }
403
+ return policy?.version ?? undefined;
404
+ }
294
405
  }
295
406
  export async function reconcileLiveGuildCommands(input) {
296
407
  let observed;
@@ -302,6 +413,68 @@ export async function reconcileLiveGuildCommands(input) {
302
413
  ? "discord_rate_limited"
303
414
  : "discord_observation_failed");
304
415
  }
416
+ // A name match is not an ownership match. Delete a tracked Nexus ID before
417
+ // the ordinary bulk writer can reuse it for a different module's root.
418
+ const handedOffRoots = new Set();
419
+ const retiredDuringRun = new Set();
420
+ if (diffGuildCommands(input.projection, observed).conflicts.length > 0)
421
+ throw new GuildCommandLiveError("discord_command_conflict");
422
+ const handoffs = (input.projection.rootMappings ?? []).filter((mapping) => mapping.observedRoot !== null &&
423
+ mapping.observedCommandId !== null &&
424
+ input.projection.definitions.some(({ name, type, ownerModuleId }) => type === 1 &&
425
+ name === mapping.observedRoot &&
426
+ ownerModuleId !== "helyx.nexus-core"));
427
+ for (const mapping of handoffs) {
428
+ if (mapping.applicationId !== input.request.applicationId ||
429
+ mapping.guildId !== input.request.guildId ||
430
+ mapping.canonicalRoot !== "nexus")
431
+ throw new GuildCommandLiveError("unsupported_root_mapping");
432
+ if (!(await currentLease()))
433
+ return null;
434
+ handedOffRoots.add(mapping.observedRoot);
435
+ retiredDuringRun.add(mapping.observedCommandId);
436
+ observed = await removeTrackedId(mapping.observedCommandId, mapping.observedRoot);
437
+ if (!(await currentLease()))
438
+ return null;
439
+ let retired;
440
+ try {
441
+ retired = await input.repository.retireRootObservation({
442
+ request: input.request,
443
+ workerId: input.workerId,
444
+ fencingToken: input.fencingToken,
445
+ canonicalRoot: mapping.canonicalRoot,
446
+ settingsRevision: mapping.settingsRevision,
447
+ desiredRevision: mapping.desiredRevision,
448
+ expectedObservedRoot: mapping.observedRoot,
449
+ expectedObservedCommandId: mapping.observedCommandId,
450
+ });
451
+ }
452
+ catch {
453
+ throw new GuildCommandLiveError("discord_write_ambiguous");
454
+ }
455
+ if (!retired)
456
+ return null;
457
+ }
458
+ // A previous attempt may have committed retirement and then crashed before
459
+ // installing the new owner. The scoped ledger also protects that retry path.
460
+ for (const command of [...observed]) {
461
+ if (!command.id ||
462
+ !/^\d{17,20}$/u.test(command.id) ||
463
+ (command.type ?? 1) !== 1 ||
464
+ !input.projection.definitions.some(({ name, type, ownerModuleId }) => type === 1 &&
465
+ name === command.name &&
466
+ ownerModuleId !== "helyx.nexus-core"))
467
+ continue;
468
+ if (await input.repository.isRetiredRootCommandId(input.request.applicationId, input.request.guildId, command.id)) {
469
+ if (!(await currentLease()))
470
+ return null;
471
+ handedOffRoots.add(command.name);
472
+ retiredDuringRun.add(command.id);
473
+ observed = await removeTrackedId(command.id, command.name);
474
+ if (!(await currentLease()))
475
+ return null;
476
+ }
477
+ }
305
478
  const diff = diffGuildCommands(input.projection, observed);
306
479
  if (diff.conflicts.length > 0)
307
480
  throw new GuildCommandLiveError("discord_command_conflict");
@@ -326,11 +499,19 @@ export async function reconcileLiveGuildCommands(input) {
326
499
  throw error;
327
500
  }
328
501
  }
329
- const current = await input.repository.isLeaseCurrent({
330
- request: input.request,
331
- workerId: input.workerId,
332
- fencingToken: input.fencingToken,
333
- });
502
+ let current;
503
+ try {
504
+ current = await currentLease();
505
+ }
506
+ catch (error) {
507
+ if (ledgerId)
508
+ await input.repository.settleCreation({
509
+ ledgerId,
510
+ state: "released",
511
+ observedReference: "write_not_started",
512
+ });
513
+ throw error;
514
+ }
334
515
  if (!current || input.signal?.aborted) {
335
516
  if (ledgerId)
336
517
  await input.repository.settleCreation({
@@ -385,26 +566,175 @@ export async function reconcileLiveGuildCommands(input) {
385
566
  });
386
567
  throw new GuildCommandLiveError("discord_write_mismatch");
387
568
  }
569
+ observed = written;
388
570
  }
389
- const outcome = await input.repository.completeLive({
390
- request: input.request,
391
- workerId: input.workerId,
392
- fencingToken: input.fencingToken,
393
- definitionHash: input.projection.definitionHash,
394
- rootCount: input.projection.rootCount,
395
- subcommandCount: input.projection.subcommandCount,
396
- observedDefinitionHash: input.projection.definitionHash,
397
- installedRootCount: input.projection.rootCount,
398
- createdCount: diff.create.length,
399
- updatedCount: diff.update.length,
400
- removedCount: diff.remove.length,
401
- createdCommands: diff.create.map(({ name }) => `/${name}`),
402
- updatedCommands: diff.update.map(({ name }) => `/${name}`),
403
- removedCommands: diff.remove.map(({ name }) => `/${name}`),
404
- ...(ledgerId ? { ledgerId } : {}),
405
- observedReference: `discord:guild:${input.request.guildId}:roots:${input.projection.rootCount}`,
406
- });
571
+ const rootMappings = input.projection.rootMappings ?? [];
572
+ if (rootMappings.length > 0) {
573
+ // A successful replacement response alone does not activate a new route.
574
+ // Read the full collection back before recording its actual Discord ID.
575
+ try {
576
+ observed = await input.discord.observe(input.request.guildId);
577
+ }
578
+ catch {
579
+ if (ledgerId)
580
+ await input.repository.settleCreation({
581
+ ledgerId,
582
+ state: "committed",
583
+ observedReference: `discord:guild:${input.request.guildId}:root_readback_ambiguous`,
584
+ });
585
+ throw new GuildCommandLiveError("discord_observation_failed");
586
+ }
587
+ if (!hasNoChanges(diffGuildCommands(input.projection, observed))) {
588
+ if (ledgerId)
589
+ await input.repository.settleCreation({
590
+ ledgerId,
591
+ state: "committed",
592
+ observedReference: `discord:guild:${input.request.guildId}:root_readback_mismatch`,
593
+ });
594
+ throw new GuildCommandLiveError("discord_write_mismatch");
595
+ }
596
+ }
597
+ const rootObservations = [];
598
+ try {
599
+ await input.validateWrite?.();
600
+ for (const command of observed) {
601
+ if (handedOffRoots.has(command.name) &&
602
+ (!command.id ||
603
+ !/^\d{17,20}$/u.test(command.id) ||
604
+ (command.application_id !== undefined &&
605
+ command.application_id !== input.request.applicationId) ||
606
+ (command.guild_id !== undefined &&
607
+ command.guild_id !== input.request.guildId)))
608
+ throw new GuildCommandLiveError("discord_observation_failed");
609
+ if (command.id && retiredDuringRun.has(command.id))
610
+ throw new GuildCommandLiveError("discord_write_mismatch");
611
+ if (command.id &&
612
+ /^\d{17,20}$/u.test(command.id) &&
613
+ input.projection.definitions.some(({ name, type, ownerModuleId }) => type === 1 &&
614
+ name === command.name &&
615
+ ownerModuleId !== "helyx.nexus-core") &&
616
+ (await input.repository.isRetiredRootCommandId(input.request.applicationId, input.request.guildId, command.id)))
617
+ throw new GuildCommandLiveError("discord_write_mismatch");
618
+ }
619
+ }
620
+ catch (error) {
621
+ if (ledgerId)
622
+ await retainCreatedCapacity();
623
+ throw error instanceof GuildCommandLiveError
624
+ ? error
625
+ : new GuildCommandLiveError("discord_observation_failed");
626
+ }
627
+ for (const mapping of rootMappings) {
628
+ const projected = input.projection.definitions.find(({ name, type, ownerModuleId }) => name === mapping.desiredRoot &&
629
+ type === 1 &&
630
+ ownerModuleId === "helyx.nexus-core");
631
+ const actual = projected
632
+ ? observed.find(({ name, type }) => name === mapping.desiredRoot && (type ?? 1) === 1)
633
+ : undefined;
634
+ if (projected && (!actual?.id || !/^\d{17,20}$/u.test(actual.id))) {
635
+ if (ledgerId)
636
+ await input.repository.settleCreation({
637
+ ledgerId,
638
+ state: "committed",
639
+ observedReference: `discord:guild:${input.request.guildId}:root_id_unverified`,
640
+ });
641
+ throw new GuildCommandLiveError("discord_observation_failed");
642
+ }
643
+ rootObservations.push({
644
+ canonicalRoot: mapping.canonicalRoot,
645
+ desiredRevision: mapping.desiredRevision,
646
+ settingsRevision: mapping.settingsRevision,
647
+ observedRoot: actual?.name ?? null,
648
+ observedCommandId: actual?.id ?? null,
649
+ });
650
+ }
651
+ let outcome;
652
+ try {
653
+ outcome = await input.repository.completeLive({
654
+ request: input.request,
655
+ workerId: input.workerId,
656
+ fencingToken: input.fencingToken,
657
+ definitionHash: input.projection.definitionHash,
658
+ rootCount: input.projection.rootCount,
659
+ subcommandCount: input.projection.subcommandCount,
660
+ observedDefinitionHash: input.projection.definitionHash,
661
+ installedRootCount: input.projection.rootCount,
662
+ createdCount: diff.create.length,
663
+ updatedCount: diff.update.length,
664
+ removedCount: diff.remove.length,
665
+ createdCommands: diff.create.map(({ name }) => `/${name}`),
666
+ updatedCommands: diff.update.map(({ name }) => `/${name}`),
667
+ removedCommands: diff.remove.map(({ name }) => `/${name}`),
668
+ ...(rootObservations.length > 0 ? { rootObservations } : {}),
669
+ ...(ledgerId ? { ledgerId } : {}),
670
+ observedReference: `discord:guild:${input.request.guildId}:roots:${input.projection.rootCount}`,
671
+ });
672
+ }
673
+ catch (error) {
674
+ if (ledgerId)
675
+ await retainCreatedCapacity();
676
+ throw error;
677
+ }
678
+ if (ledgerId && outcome !== "completed")
679
+ await retainCreatedCapacity();
407
680
  return { outcome, diff };
681
+ async function currentLease() {
682
+ if (input.signal?.aborted ||
683
+ !(await input.repository.isLeaseCurrent({
684
+ request: input.request,
685
+ workerId: input.workerId,
686
+ fencingToken: input.fencingToken,
687
+ })))
688
+ return false;
689
+ // Final freshness must follow every potentially delayed catalogue/lease read.
690
+ await input.validateWrite?.();
691
+ return !input.signal?.aborted;
692
+ }
693
+ async function removeTrackedId(commandId, expectedName) {
694
+ const target = observed.find(({ id }) => id === commandId);
695
+ if (observed.some(({ name, id }) => name === expectedName && !id) ||
696
+ (target &&
697
+ (target.name !== expectedName ||
698
+ (target.type ?? 1) !== 1 ||
699
+ (target.application_id !== undefined &&
700
+ target.application_id !== input.request.applicationId) ||
701
+ (target.guild_id !== undefined &&
702
+ target.guild_id !== input.request.guildId))))
703
+ throw new GuildCommandLiveError("discord_observation_failed");
704
+ if (!target)
705
+ return observed;
706
+ try {
707
+ await input.discord.remove(input.request.guildId, commandId);
708
+ }
709
+ catch {
710
+ /* A timeout or 404 is settled only by the independent readback. */
711
+ }
712
+ let readback;
713
+ try {
714
+ readback = await input.discord.observe(input.request.guildId);
715
+ }
716
+ catch {
717
+ throw new GuildCommandLiveError("discord_observation_failed");
718
+ }
719
+ if (readback.some(({ id }) => id === commandId) ||
720
+ readback.some(({ name, id }) => name === expectedName && !id))
721
+ throw new GuildCommandLiveError("discord_write_mismatch");
722
+ return readback;
723
+ }
724
+ async function retainCreatedCapacity() {
725
+ // Discord was already written or read back successfully. A failed/fenced
726
+ // completion must not release real creations when its reservation expires.
727
+ try {
728
+ await input.repository.settleCreation({
729
+ ledgerId: ledgerId,
730
+ state: "committed",
731
+ observedReference: `discord:guild:${input.request.guildId}:completion_unacknowledged`,
732
+ });
733
+ }
734
+ catch {
735
+ // Preserve the original completion error; durable retry still owns it.
736
+ }
737
+ }
408
738
  }
409
739
  class GuildCommandLiveError extends Error {
410
740
  safeCode;
@@ -0,0 +1,7 @@
1
+ import type { Message } from "discord.js";
2
+ /** Pinned cache compatibility: public member.roles.cache silently omits uncached assigned IDs. */
3
+ export declare function snapshotProtectionAuthorRoles(message: Message, webhook: boolean): Readonly<{
4
+ authorRoleIds: readonly string[];
5
+ authorRolesResolved: boolean;
6
+ }>;
7
+ //# sourceMappingURL=guild-message-protection-author-roles.d.ts.map
@@ -0,0 +1,93 @@
1
+ const maximumAssignedRoles = 250; // Private snapshot bound, not a Discord product limit.
2
+ const maximumPublishedRoles = 250;
3
+ const snowflake = /^\d{17,20}$/u;
4
+ const unknown = Object.freeze({
5
+ authorRoleIds: Object.freeze([]),
6
+ authorRolesResolved: false,
7
+ });
8
+ const webhookRoles = Object.freeze({
9
+ authorRoleIds: Object.freeze([]),
10
+ authorRolesResolved: true,
11
+ });
12
+ /** Pinned cache compatibility: public member.roles.cache silently omits uncached assigned IDs. */
13
+ export function snapshotProtectionAuthorRoles(message, webhook) {
14
+ if (webhook)
15
+ return webhookRoles;
16
+ try {
17
+ const guildId = message.guildId, authorId = message.author.id;
18
+ const client = message.client, guild = message.guild, member = message.member;
19
+ if (!guildId ||
20
+ !snowflake.test(guildId) ||
21
+ !snowflake.test(authorId) ||
22
+ !guild ||
23
+ !member ||
24
+ guild.id !== guildId ||
25
+ guild.client !== client ||
26
+ guild.available !== true ||
27
+ client.guilds.cache.get(guildId) !== guild ||
28
+ member.guild !== guild ||
29
+ member.client !== client ||
30
+ member.id !== authorId ||
31
+ member.user.id !== authorId ||
32
+ member.partial !== false ||
33
+ guild.members.cache.get(authorId) !== member)
34
+ return unknown;
35
+ const assigned = Reflect.get(member, "_roles");
36
+ if (!Array.isArray(assigned) || assigned.length > maximumAssignedRoles)
37
+ return unknown;
38
+ const length = assigned.length, snapshot = [], seen = new Set();
39
+ for (let index = 0; index < length; index++) {
40
+ const id = assigned[index];
41
+ if (typeof id !== "string" ||
42
+ !snowflake.test(id) ||
43
+ id === guildId ||
44
+ seen.has(id))
45
+ return unknown;
46
+ seen.add(id);
47
+ snapshot.push(id);
48
+ }
49
+ if (assigned.length !== length)
50
+ return unknown;
51
+ seen.add(guildId);
52
+ const roles = [];
53
+ // Role IDs suffice for exemption metadata. No permission bits/names or member fetch.
54
+ for (const id of seen) {
55
+ const role = guild.roles.cache.get(id);
56
+ if (!role ||
57
+ role.id !== id ||
58
+ role.guild !== guild ||
59
+ role.client !== client)
60
+ return unknown;
61
+ roles.push({ id, role });
62
+ }
63
+ for (const { id, role } of roles) {
64
+ if (guild.roles.cache.get(id) !== role ||
65
+ role.id !== id ||
66
+ role.guild !== guild ||
67
+ role.client !== client)
68
+ return unknown;
69
+ }
70
+ if (client.guilds.cache.get(guildId) !== guild ||
71
+ guild.available !== true ||
72
+ message.guildId !== guildId ||
73
+ message.author.id !== authorId ||
74
+ guild.members.cache.get(authorId) !== member ||
75
+ member.guild !== guild ||
76
+ member.client !== client ||
77
+ member.id !== authorId ||
78
+ member.partial !== false ||
79
+ Reflect.get(member, "_roles") !== assigned ||
80
+ assigned.length !== length ||
81
+ snapshot.some((id, index) => assigned[index] !== id))
82
+ return unknown;
83
+ const ids = [...seen].sort();
84
+ return Object.freeze({
85
+ authorRoleIds: Object.freeze(ids.slice(0, maximumPublishedRoles)),
86
+ authorRolesResolved: ids.length <= maximumPublishedRoles,
87
+ });
88
+ }
89
+ catch {
90
+ return unknown;
91
+ }
92
+ }
93
+ //# sourceMappingURL=guild-message-protection-author-roles.js.map
@@ -0,0 +1,21 @@
1
+ import type { GuildMessageProtectionDeletionResult } from "@helyx/sdk";
2
+ import type { Client, GuildBasedChannel } from "discord.js";
3
+ import { type CachedMessageDeletionAuthority, type CachedMessageDeletionScope } from "./cached-message-deletion-authority.js";
4
+ import type { EmergencyDeletionDispatcher } from "./emergency-deletion-dispatcher.js";
5
+ import type { MessageProtectionEligibility } from "./guild-message-protection-delivery.js";
6
+ export type MessageProtectionAuthorityFactory = (source: Readonly<CachedMessageDeletionScope & {
7
+ channel: GuildBasedChannel;
8
+ }>, isStopped: () => boolean) => CachedMessageDeletionAuthority | undefined;
9
+ /** Separate lexical scope: queued grants never capture an execute/Message environment. */
10
+ export declare function createProtectionDeliveryLifetime(): Readonly<{
11
+ isStopped: () => boolean;
12
+ stop: () => void;
13
+ }>;
14
+ /** Explicit private composition only; never derive configuration from mutable client identity. */
15
+ export declare function createMessageProtectionAuthorityFactory(client: Client, expectedApplicationId: string | undefined, eligibility: MessageProtectionEligibility | undefined, deletion: EmergencyDeletionDispatcher | undefined): MessageProtectionAuthorityFactory | undefined;
16
+ /** Handler-local owner; only its content-free grant is handed to the queue. */
17
+ export declare function createProtectionDeletionOperation(input: CachedMessageDeletionScope, deletion: EmergencyDeletionDispatcher | undefined, strict: boolean, captured: CachedMessageDeletionAuthority | undefined, isActive: () => boolean): Readonly<{
18
+ releaseUnused: () => void;
19
+ invoke: () => Promise<GuildMessageProtectionDeletionResult>;
20
+ }>;
21
+ //# sourceMappingURL=guild-message-protection-deletion.d.ts.map