@helyx/bot 0.4.23 → 0.4.24

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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.24
4
+
5
+ ### Patch Changes
6
+
7
+ - Reuse active Verification links when available, report their expiry, and allow members to replace them while atomically invalidating the old request.---
8
+ "@helyx/bot": patch
9
+ "@helyx/database": patch
10
+ "@helyx/sdk": patch
11
+ "@helyx/verification-contracts": patch
12
+ ---
13
+
14
+ Reuse active Verification links when available, report their expiry, and allow members to replace them while atomically invalidating the old request.
15
+
16
+ - Accept compatible internal Helyx releases within explicit minor-safe pre-1.0 or major-safe stable ranges, preventing dependency-only patch cascades.---
17
+ "@helyx/bot": patch
18
+ "@helyx/cli": patch
19
+ "@helyx/core": patch
20
+ "@helyx/framework": patch
21
+ "@helyx/platform": patch
22
+ "@helyx/sdk": patch
23
+ "@helyx/module-account-gate": patch
24
+ "@helyx/module-feedback": patch
25
+ "@helyx/module-giveaways": patch
26
+ "@helyx/module-levels": patch
27
+ "@helyx/module-logging": patch
28
+ "@helyx/module-polls": patch
29
+ "@helyx/module-reactionroles": patch
30
+ "@helyx/module-suggestions": patch
31
+ "@helyx/module-welcome": patch
32
+ ---
33
+
34
+ Accept compatible internal Helyx releases within explicit minor-safe pre-1.0 or major-safe stable ranges, preventing dependency-only patch cascades.
35
+
3
36
  ## 0.4.23
4
37
 
5
38
  ### Patch Changes
@@ -1,4 +1,5 @@
1
1
  import { actionReceiptSchema, challengeCreateRequestSchema, challengeCreateResponseSchema, dashboardAttemptPageSchema, dashboardAttemptQuerySchema, dashboardSafeAttemptDetailSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
2
+ import { VerificationChallengeActiveError } from "@helyx/sdk";
2
3
  import { createPublicKey, verify } from "node:crypto";
3
4
  import { z } from "zod";
4
5
  const healthSchema = z
@@ -8,12 +9,19 @@ const healthSchema = z
8
9
  allowanceRemaining: z.number().int().min(0).optional(),
9
10
  })
10
11
  .strict();
12
+ const activeChallengeSchema = z
13
+ .object({
14
+ message: z.literal("A verification link is already active."),
15
+ expiresAt: z.iso.datetime(),
16
+ })
17
+ .strict();
11
18
  export class HttpVerificationService {
12
19
  #baseUrl;
13
20
  #internalSecret;
14
21
  #publicSigningKeys;
15
22
  #fetch;
16
23
  #installationId;
24
+ #challengeCache = new Map();
17
25
  #handler;
18
26
  constructor(options) {
19
27
  this.#baseUrl = options.baseUrl.replace(/\/$/u, "");
@@ -40,19 +48,36 @@ export class HttpVerificationService {
40
48
  });
41
49
  }
42
50
  async createChallenge(input) {
43
- return challengeCreateResponseSchema.parse(await this.#request("/internal/v1/challenges", {
51
+ const cacheKey = challengeCacheKey(input.installation.installationId, input.guildId, input.userId);
52
+ this.#pruneChallengeCache();
53
+ const cached = this.#challengeCache.get(cacheKey);
54
+ if (input.replaceActive !== true &&
55
+ cached &&
56
+ Date.parse(cached.expiresAt) > Date.now()) {
57
+ return challengeCreateResponseSchema.parse({ ...cached, reused: true });
58
+ }
59
+ const created = challengeCreateResponseSchema.parse(await this.#request("/internal/v1/challenges", {
44
60
  method: "POST",
45
61
  body: challengeCreateRequestSchema.parse(input),
46
62
  }));
63
+ this.#challengeCache.set(cacheKey, created);
64
+ return created;
47
65
  }
48
66
  async cancelChallenges(input) {
49
- return z
67
+ const cancelled = z
50
68
  .object({ cancelled: z.number().int().min(0) })
51
69
  .strict()
52
70
  .parse(await this.#request("/internal/v1/challenges/cancel", {
53
71
  method: "POST",
54
72
  body: input,
55
73
  }));
74
+ const prefix = `${input.installationId}:${input.guildId}:`;
75
+ for (const key of this.#challengeCache.keys()) {
76
+ if (key.startsWith(prefix) &&
77
+ (input.userId === undefined || key === `${prefix}${input.userId}`))
78
+ this.#challengeCache.delete(key);
79
+ }
80
+ return cancelled;
56
81
  }
57
82
  async listAttempts(input) {
58
83
  const { installationId, guildId, ...query } = input;
@@ -99,10 +124,12 @@ export class HttpVerificationService {
99
124
  throw new Error("Verification assessment is outside its valid lifetime");
100
125
  if (!this.#handler)
101
126
  throw new Error("Verification result handler is unavailable");
102
- return actionReceiptSchema.parse(await this.#handler({
127
+ const receipt = actionReceiptSchema.parse(await this.#handler({
103
128
  envelope,
104
129
  correlationId: envelope.payload.attemptId,
105
130
  }));
131
+ this.#challengeCache.delete(challengeCacheKey(envelope.payload.installationId, envelope.payload.guildId, envelope.payload.userId));
132
+ return receipt;
106
133
  }
107
134
  async health() {
108
135
  const health = healthSchema.parse(await this.#request("/internal/v1/health", { method: "GET" }));
@@ -138,8 +165,12 @@ export class HttpVerificationService {
138
165
  if (allowNotFound && response.status === 404)
139
166
  return null;
140
167
  if (!response.ok)
141
- if (response.status === 409)
142
- throw new Error("A verification link is already active. Wait for it to expire before requesting another.");
168
+ if (response.status === 409) {
169
+ const active = activeChallengeSchema.safeParse(await response.json());
170
+ if (active.success)
171
+ throw new VerificationChallengeActiveError(active.data.expiresAt);
172
+ throw new Error("Verification request was rejected");
173
+ }
143
174
  else if (response.status === 429)
144
175
  throw new Error("Verification requests are temporarily limited. Try again shortly.");
145
176
  else
@@ -153,6 +184,22 @@ export class HttpVerificationService {
153
184
  throw new Error("Verification service returned an invalid response");
154
185
  }
155
186
  }
187
+ #pruneChallengeCache() {
188
+ const now = Date.now();
189
+ for (const [key, challenge] of this.#challengeCache) {
190
+ if (Date.parse(challenge.expiresAt) <= now)
191
+ this.#challengeCache.delete(key);
192
+ }
193
+ while (this.#challengeCache.size >= 10_000) {
194
+ const oldestKey = this.#challengeCache.keys().next().value;
195
+ if (oldestKey === undefined)
196
+ break;
197
+ this.#challengeCache.delete(oldestKey);
198
+ }
199
+ }
200
+ }
201
+ function challengeCacheKey(installationId, guildId, userId) {
202
+ return `${installationId}:${guildId}:${userId}`;
156
203
  }
157
204
  function verifyEnvelope(envelope, publicKeySpkiBase64) {
158
205
  let key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -48,15 +48,15 @@
48
48
  "start": "node --env-file-if-exists=../../.env dist/index.js"
49
49
  },
50
50
  "dependencies": {
51
- "@helyx/config": "0.5.0",
52
- "@helyx/control-protocol": "0.3.2",
53
- "@helyx/core": "0.1.16",
54
- "@helyx/database": "0.7.5",
55
- "@helyx/framework": "0.1.16",
56
- "@helyx/module-manifest": "0.6.0",
57
- "@helyx/platform": "0.4.7",
58
- "@helyx/sdk": "0.6.3",
59
- "@helyx/verification-contracts": "0.3.1",
51
+ "@helyx/config": ">=0.5.0 <0.6.0",
52
+ "@helyx/control-protocol": ">=0.3.0 <0.4.0",
53
+ "@helyx/core": ">=0.1.0 <0.2.0",
54
+ "@helyx/database": ">=0.7.0 <0.8.0",
55
+ "@helyx/framework": ">=0.1.0 <0.2.0",
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",
59
+ "@helyx/verification-contracts": ">=0.3.0 <0.4.0",
60
60
  "discord.js": "14.27.0",
61
61
  "fastify": "5.10.0",
62
62
  "pino": "10.3.1",