@helyx/bot 1.1.0 → 1.2.0

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 (85) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +16 -1
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/discord-analytics.d.ts +3 -0
  23. package/dist/discord-analytics.js +49 -5
  24. package/dist/discord-auto-moderation-service.d.ts +21 -0
  25. package/dist/discord-auto-moderation-service.js +425 -0
  26. package/dist/discord-guild-health.d.ts +2 -2
  27. package/dist/discord-member-enforcement-service.d.ts +26 -0
  28. package/dist/discord-member-enforcement-service.js +590 -0
  29. package/dist/discord-message-enforcement-service.d.ts +9 -0
  30. package/dist/discord-message-enforcement-service.js +102 -0
  31. package/dist/discord-violation-thread-service.d.ts +33 -0
  32. package/dist/discord-violation-thread-service.js +789 -0
  33. package/dist/ecko-evidence-receiver.d.ts +32 -0
  34. package/dist/ecko-evidence-receiver.js +173 -0
  35. package/dist/ecko-transport-auth.d.ts +19 -0
  36. package/dist/ecko-transport-auth.js +80 -0
  37. package/dist/ecko-transport-routes.d.ts +19 -0
  38. package/dist/ecko-transport-routes.js +130 -0
  39. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  40. package/dist/embedded-auto-moderation-pool.js +324 -0
  41. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  42. package/dist/embedded-auto-moderation-scanner.js +263 -0
  43. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  44. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  45. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  46. package/dist/embedded-auto-moderation-worker.js +13 -0
  47. package/dist/events.d.ts +5 -1
  48. package/dist/events.js +243 -4
  49. package/dist/guilds.d.ts +2 -2
  50. package/dist/guilds.js +14 -8
  51. package/dist/hosted-server-moderation.d.ts +16 -5
  52. package/dist/hosted-server-moderation.js +32 -4
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.js +84 -14
  55. package/dist/moderation-action-registry.d.ts +11 -0
  56. package/dist/moderation-action-registry.js +375 -0
  57. package/dist/moderation-case-disassociation.d.ts +13 -0
  58. package/dist/moderation-case-disassociation.js +83 -0
  59. package/dist/moderation-platform-services.d.ts +27 -0
  60. package/dist/moderation-platform-services.js +47 -0
  61. package/dist/modules.d.ts +6 -0
  62. package/dist/modules.js +65 -3
  63. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  64. package/dist/privileged-message-metadata-cache.js +70 -0
  65. package/dist/scheduled-task-runner.js +24 -1
  66. package/dist/shard-contracts.d.ts +151 -1
  67. package/dist/shard-contracts.js +142 -1
  68. package/dist/shard-coordinator.d.ts +1 -1
  69. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  70. package/dist/shard-ecko-scanner-registry.js +96 -0
  71. package/dist/shard-router.d.ts +23 -0
  72. package/dist/shard-router.js +69 -5
  73. package/dist/shard-supervisor.d.ts +11 -0
  74. package/dist/shard-supervisor.js +67 -4
  75. package/dist/shard-worker.d.ts +15 -1
  76. package/dist/shard-worker.js +95 -17
  77. package/dist/transient-violation-evidence.d.ts +39 -0
  78. package/dist/transient-violation-evidence.js +132 -0
  79. package/documentation/README.md +2 -0
  80. package/documentation/examples/.env.example +12 -0
  81. package/documentation/guides/self-hosting.md +17 -1
  82. package/documentation/modules/auto-moderation.md +51 -0
  83. package/documentation/modules/moderation.md +42 -0
  84. package/documentation/reference/environment.md +11 -0
  85. package/package.json +17 -5
@@ -0,0 +1,32 @@
1
+ import { type ViolationEvidenceReference } from "@helyx/sdk";
2
+ import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
3
+ export declare const ECKO_EVIDENCE_METHOD = "POST";
4
+ export declare const ECKO_EVIDENCE_PATH = "/v1/automod/enhanced/evidence";
5
+ export type EckoEvidenceAcceptance = {
6
+ outcome: "accepted";
7
+ evidenceReference: ViolationEvidenceReference;
8
+ } | {
9
+ outcome: "rejected";
10
+ code: "invalid_envelope" | "unknown_version" | "signature_invalid" | "stale" | "replay" | "capacity_exceeded";
11
+ };
12
+ /**
13
+ * Bot-private receiver for the Hosted evidence transport. The HTTP adapter must
14
+ * pass the exact received bytes; authentication is over those bytes, not a
15
+ * parsed/re-serialized object. This receiver is not registered in ServiceAccess.
16
+ */
17
+ export declare class EckoEvidenceReceiver {
18
+ #private;
19
+ private readonly evidenceStore;
20
+ private readonly environment;
21
+ private readonly now;
22
+ private readonly maximumAuthenticatedEnvelopesPerSecond;
23
+ constructor(evidenceStore: TransientViolationEvidenceStore, environment: string, hmacKey: Uint8Array, now?: () => number, maximumAuthenticatedEnvelopesPerSecond?: number);
24
+ accept(input: {
25
+ rawBody: Uint8Array;
26
+ signature: string;
27
+ }): EckoEvidenceAcceptance;
28
+ /** Used only after the enclosing Ecko transport request is authenticated. */
29
+ acceptAuthenticated(rawBody: Uint8Array): EckoEvidenceAcceptance;
30
+ }
31
+ export declare function signEckoEvidenceBody(rawBody: Uint8Array, hmacKey: Uint8Array): string;
32
+ //# sourceMappingURL=ecko-evidence-receiver.d.ts.map
@@ -0,0 +1,173 @@
1
+ import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_EVIDENCE_PROTOCOL, AUTOMOD_ENHANCED_PROVIDER_ID, AUTOMOD_SCANNER_LIMITS, } from "@helyx/sdk";
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ import { z } from "zod";
4
+ const SNOWFLAKE = z.string().regex(/^\d{17,20}$/u);
5
+ const evidenceEnvelopeSchema = z
6
+ .object({
7
+ protocol: z.literal(AUTOMOD_ENHANCED_EVIDENCE_PROTOCOL),
8
+ providerId: z.literal(AUTOMOD_ENHANCED_PROVIDER_ID),
9
+ contractVersion: z.literal(AUTOMOD_ENHANCED_CONTRACT_VERSION),
10
+ operationId: z.string().regex(/^[a-f0-9]{64}$/u),
11
+ nonce: z.string().regex(/^[A-Za-z0-9_-]{32,128}$/u),
12
+ issuedAt: z.iso.datetime({ offset: true }),
13
+ deadlineAt: z.iso.datetime({ offset: true }),
14
+ environment: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u),
15
+ guildId: SNOWFLAKE,
16
+ channelId: SNOWFLAKE,
17
+ messageId: SNOWFLAKE,
18
+ authorUserId: SNOWFLAKE,
19
+ configurationRevision: z
20
+ .string()
21
+ .min(1)
22
+ .max(160)
23
+ .refine((value) => !/[\r\n]/u.test(value)),
24
+ content: z.string().min(1).max(AUTOMOD_SCANNER_LIMITS.messageCharacters),
25
+ })
26
+ .strict();
27
+ const MAX_REPLAY_ENTRIES = 20_000;
28
+ const MAX_AUTHENTICATED_ENVELOPES_PER_SECOND = 500;
29
+ export const ECKO_EVIDENCE_METHOD = "POST";
30
+ export const ECKO_EVIDENCE_PATH = "/v1/automod/enhanced/evidence";
31
+ /**
32
+ * Bot-private receiver for the Hosted evidence transport. The HTTP adapter must
33
+ * pass the exact received bytes; authentication is over those bytes, not a
34
+ * parsed/re-serialized object. This receiver is not registered in ServiceAccess.
35
+ */
36
+ export class EckoEvidenceReceiver {
37
+ evidenceStore;
38
+ environment;
39
+ now;
40
+ maximumAuthenticatedEnvelopesPerSecond;
41
+ #replayEntries = new Map();
42
+ #key;
43
+ #rateWindowStartedAt = Number.NEGATIVE_INFINITY;
44
+ #rateWindowCount = 0;
45
+ constructor(evidenceStore, environment, hmacKey, now = Date.now, maximumAuthenticatedEnvelopesPerSecond = MAX_AUTHENTICATED_ENVELOPES_PER_SECOND) {
46
+ this.evidenceStore = evidenceStore;
47
+ this.environment = environment;
48
+ this.now = now;
49
+ this.maximumAuthenticatedEnvelopesPerSecond = maximumAuthenticatedEnvelopesPerSecond;
50
+ this.#key = Buffer.from(hmacKey);
51
+ if (this.#key.byteLength < 32)
52
+ throw new RangeError("Ecko evidence HMAC key must contain 32 bytes");
53
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u.test(environment))
54
+ throw new Error("Ecko evidence environment is invalid");
55
+ if (!Number.isSafeInteger(maximumAuthenticatedEnvelopesPerSecond) ||
56
+ maximumAuthenticatedEnvelopesPerSecond < 1 ||
57
+ maximumAuthenticatedEnvelopesPerSecond >
58
+ MAX_AUTHENTICATED_ENVELOPES_PER_SECOND)
59
+ throw new RangeError("Ecko evidence ingress limit is invalid");
60
+ }
61
+ accept(input) {
62
+ const body = Buffer.from(input.rawBody);
63
+ if (body.byteLength < 1 ||
64
+ body.byteLength > AUTOMOD_SCANNER_LIMITS.evidenceBytes)
65
+ return rejected("invalid_envelope");
66
+ if (!verifySignature(body, input.signature, this.#key))
67
+ return rejected("signature_invalid");
68
+ return this.#acceptAuthenticatedBody(body);
69
+ }
70
+ /** Used only after the enclosing Ecko transport request is authenticated. */
71
+ acceptAuthenticated(rawBody) {
72
+ const body = Buffer.from(rawBody);
73
+ if (body.byteLength < 1 ||
74
+ body.byteLength > AUTOMOD_SCANNER_LIMITS.evidenceBytes)
75
+ return rejected("invalid_envelope");
76
+ return this.#acceptAuthenticatedBody(body);
77
+ }
78
+ #acceptAuthenticatedBody(body) {
79
+ let decoded;
80
+ try {
81
+ decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
82
+ }
83
+ catch {
84
+ return rejected("invalid_envelope");
85
+ }
86
+ const parsed = evidenceEnvelopeSchema.safeParse(decoded);
87
+ if (!parsed.success)
88
+ return rejected(hasUnknownProtocolIdentity(decoded)
89
+ ? "unknown_version"
90
+ : "invalid_envelope");
91
+ const envelope = parsed.data;
92
+ if (envelope.environment !== this.environment)
93
+ return rejected("invalid_envelope");
94
+ if (Buffer.byteLength(envelope.content, "utf8") >
95
+ AUTOMOD_SCANNER_LIMITS.messageUtf8Bytes)
96
+ return rejected("invalid_envelope");
97
+ const issuedAt = Date.parse(envelope.issuedAt);
98
+ const deadlineAt = Date.parse(envelope.deadlineAt);
99
+ const now = this.now();
100
+ if (deadlineAt <= issuedAt ||
101
+ deadlineAt - issuedAt >
102
+ AUTOMOD_SCANNER_LIMITS.evidenceDeadlineSeconds * 1_000 ||
103
+ issuedAt > now + AUTOMOD_SCANNER_LIMITS.clockSkewSeconds * 1_000 ||
104
+ deadlineAt < now)
105
+ return rejected("stale");
106
+ if (!this.#acceptWithinRateLimit(now))
107
+ return rejected("capacity_exceeded");
108
+ this.#prune(now);
109
+ const nonceKey = `nonce:${envelope.nonce}`;
110
+ const operationKey = `operation:${envelope.operationId}`;
111
+ if (this.#replayEntries.has(nonceKey) ||
112
+ this.#replayEntries.has(operationKey))
113
+ return rejected("replay");
114
+ if (this.#replayEntries.size + 2 > MAX_REPLAY_ENTRIES)
115
+ return rejected("capacity_exceeded");
116
+ const evidenceReference = this.evidenceStore.capture({
117
+ source: "enhanced_scanner",
118
+ sourceEventId: envelope.operationId,
119
+ configurationRevision: envelope.configurationRevision,
120
+ guildId: envelope.guildId,
121
+ channelId: envelope.channelId,
122
+ messageId: envelope.messageId,
123
+ authorUserId: envelope.authorUserId,
124
+ content: envelope.content,
125
+ });
126
+ if (!evidenceReference)
127
+ return rejected("capacity_exceeded");
128
+ this.#replayEntries.set(nonceKey, deadlineAt);
129
+ this.#replayEntries.set(operationKey, deadlineAt);
130
+ return { outcome: "accepted", evidenceReference };
131
+ }
132
+ #prune(now) {
133
+ for (const [key, expiresAt] of this.#replayEntries)
134
+ if (expiresAt < now)
135
+ this.#replayEntries.delete(key);
136
+ }
137
+ #acceptWithinRateLimit(now) {
138
+ if (now - this.#rateWindowStartedAt >= 1_000) {
139
+ this.#rateWindowStartedAt = now;
140
+ this.#rateWindowCount = 0;
141
+ }
142
+ if (this.#rateWindowCount >= this.maximumAuthenticatedEnvelopesPerSecond)
143
+ return false;
144
+ this.#rateWindowCount += 1;
145
+ return true;
146
+ }
147
+ }
148
+ function hasUnknownProtocolIdentity(value) {
149
+ if (typeof value !== "object" || value === null)
150
+ return false;
151
+ const record = value;
152
+ return (record.protocol !== AUTOMOD_ENHANCED_EVIDENCE_PROTOCOL ||
153
+ record.providerId !== AUTOMOD_ENHANCED_PROVIDER_ID ||
154
+ record.contractVersion !== AUTOMOD_ENHANCED_CONTRACT_VERSION);
155
+ }
156
+ export function signEckoEvidenceBody(rawBody, hmacKey) {
157
+ return createHmac("sha256", hmacKey)
158
+ .update(`${ECKO_EVIDENCE_METHOD}\n${ECKO_EVIDENCE_PATH}\n`, "utf8")
159
+ .update(rawBody)
160
+ .digest("hex");
161
+ }
162
+ function verifySignature(body, signature, key) {
163
+ if (!/^[a-f0-9]{64}$/u.test(signature))
164
+ return false;
165
+ const expected = Buffer.from(signEckoEvidenceBody(body, key), "hex");
166
+ const actual = Buffer.from(signature, "hex");
167
+ return (actual.byteLength === expected.byteLength &&
168
+ timingSafeEqual(actual, expected));
169
+ }
170
+ function rejected(code) {
171
+ return { outcome: "rejected", code };
172
+ }
173
+ //# sourceMappingURL=ecko-evidence-receiver.js.map
@@ -0,0 +1,19 @@
1
+ export type EckoTransportAuthentication = {
2
+ authenticated: true;
3
+ } | {
4
+ authenticated: false;
5
+ code: "invalid" | "stale" | "replay" | "capacity";
6
+ };
7
+ export declare class EckoTransportAuthenticator {
8
+ #private;
9
+ private readonly environment;
10
+ private readonly now;
11
+ constructor(environment: string, secrets: readonly string[], now?: () => number);
12
+ authenticate(input: {
13
+ method: string;
14
+ path: string;
15
+ body: Uint8Array;
16
+ headers: Readonly<Record<string, string | string[] | undefined>>;
17
+ }): EckoTransportAuthentication;
18
+ }
19
+ //# sourceMappingURL=ecko-transport-auth.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ const MAX_REPLAY_ENTRIES = 250_000;
3
+ const REPLAY_WINDOW_MS = 2 * 60 * 1_000;
4
+ export class EckoTransportAuthenticator {
5
+ environment;
6
+ now;
7
+ #keys;
8
+ #replay = new Map();
9
+ constructor(environment, secrets, now = Date.now) {
10
+ this.environment = environment;
11
+ this.now = now;
12
+ this.#keys = secrets.map((secret) => Buffer.from(secret, "base64"));
13
+ if (this.#keys.length < 1 ||
14
+ this.#keys.length > 2 ||
15
+ this.#keys.some((key) => key.byteLength !== 32))
16
+ throw new Error("Ecko transport requires one or two 32-byte keys");
17
+ }
18
+ authenticate(input) {
19
+ const version = one(input.headers["x-helyx-ecko-version"]);
20
+ const environment = one(input.headers["x-helyx-ecko-environment"]);
21
+ const timestamp = one(input.headers["x-helyx-ecko-timestamp"]);
22
+ const deadline = one(input.headers["x-helyx-ecko-deadline"]);
23
+ const nonce = one(input.headers["x-helyx-ecko-nonce"]);
24
+ const signature = one(input.headers["x-helyx-ecko-signature"]);
25
+ if (version !== "1" ||
26
+ environment !== this.environment ||
27
+ !timestamp ||
28
+ !/^\d{10}$/u.test(timestamp) ||
29
+ !deadline ||
30
+ !/^\d{10}$/u.test(deadline) ||
31
+ !nonce ||
32
+ !/^[A-Za-z0-9_-]{32,128}$/u.test(nonce) ||
33
+ !signature ||
34
+ !/^[a-f0-9]{64}$/u.test(signature))
35
+ return { authenticated: false, code: "invalid" };
36
+ const now = this.now();
37
+ const issuedAt = Number(timestamp) * 1_000;
38
+ const deadlineAt = Number(deadline) * 1_000;
39
+ if (issuedAt > now + 30_000 ||
40
+ deadlineAt < now ||
41
+ deadlineAt <= issuedAt ||
42
+ deadlineAt - issuedAt > 30_000)
43
+ return { authenticated: false, code: "stale" };
44
+ const digest = createHash("sha256").update(input.body).digest("hex");
45
+ const canonical = [
46
+ version,
47
+ input.method.toUpperCase(),
48
+ input.path,
49
+ environment,
50
+ timestamp,
51
+ deadline,
52
+ nonce,
53
+ digest,
54
+ ].join("\n");
55
+ const supplied = Buffer.from(signature, "hex");
56
+ const valid = this.#keys.some((key) => {
57
+ const expected = createHmac("sha256", key).update(canonical).digest();
58
+ return (supplied.byteLength === expected.byteLength &&
59
+ timingSafeEqual(supplied, expected));
60
+ });
61
+ if (!valid)
62
+ return { authenticated: false, code: "invalid" };
63
+ this.#prune(now);
64
+ if (this.#replay.has(nonce))
65
+ return { authenticated: false, code: "replay" };
66
+ if (this.#replay.size >= MAX_REPLAY_ENTRIES)
67
+ return { authenticated: false, code: "capacity" };
68
+ this.#replay.set(nonce, Math.min(deadlineAt + REPLAY_WINDOW_MS, now + REPLAY_WINDOW_MS));
69
+ return { authenticated: true };
70
+ }
71
+ #prune(now) {
72
+ for (const [nonce, expiresAt] of this.#replay)
73
+ if (expiresAt < now)
74
+ this.#replay.delete(nonce);
75
+ }
76
+ }
77
+ function one(value) {
78
+ return Array.isArray(value) ? value[0] : value;
79
+ }
80
+ //# sourceMappingURL=ecko-transport-auth.js.map
@@ -0,0 +1,19 @@
1
+ import { type AutoModerationScannerProviderRegistryService } from "@helyx/sdk";
2
+ import type { FastifyInstance } from "fastify";
3
+ import type { EckoEvidenceReceiver } from "./ecko-evidence-receiver.js";
4
+ import type { EckoTransportAuthenticator } from "./ecko-transport-auth.js";
5
+ export interface EckoTelemetrySink {
6
+ accept(input: unknown): Promise<boolean>;
7
+ }
8
+ export declare function registerEckoTransportRoutes(server: FastifyInstance, input: {
9
+ authenticator: EckoTransportAuthenticator;
10
+ registry: AutoModerationScannerProviderRegistryService;
11
+ evidence: EckoEvidenceReceiver;
12
+ gateState(): Promise<{
13
+ state: "enabled" | "draining" | "disabled";
14
+ updatedAt: Date;
15
+ }>;
16
+ telemetry?: EckoTelemetrySink;
17
+ now?: () => number;
18
+ }): void;
19
+ //# sourceMappingURL=ecko-transport-routes.d.ts.map
@@ -0,0 +1,130 @@
1
+ import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_PROVIDER_ID, AUTOMOD_SCANNER_LIMITS, } from "@helyx/sdk";
2
+ import { z } from "zod";
3
+ export function registerEckoTransportRoutes(server, input) {
4
+ const pending = new Map();
5
+ const now = input.now ?? Date.now;
6
+ server.get("/v1/ecko/configurations", async (request, reply) => {
7
+ if (!authenticate(request, reply, input.authenticator, new Uint8Array()))
8
+ return;
9
+ if ((await input.gateState()).state !== "enabled")
10
+ return reply.code(503).send({ code: "gate_disabled" });
11
+ const query = z
12
+ .object({
13
+ cursor: z.string().min(1).max(2_048).optional(),
14
+ limit: z.coerce
15
+ .number()
16
+ .int()
17
+ .min(1)
18
+ .max(AUTOMOD_SCANNER_LIMITS.configurationPageServers),
19
+ knownFingerprint: z.string().min(1).max(128).optional(),
20
+ })
21
+ .strict()
22
+ .parse(request.query);
23
+ return input.registry.getConfigurationPage({
24
+ adapter: "hosted_ecko",
25
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
26
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
27
+ limit: query.limit,
28
+ ...(query.cursor ? { cursor: query.cursor } : {}),
29
+ ...(query.knownFingerprint
30
+ ? { knownFingerprint: query.knownFingerprint }
31
+ : {}),
32
+ });
33
+ });
34
+ server.post("/v1/ecko/evidence", { config: { rawBody: true } }, async (request, reply) => {
35
+ const body = rawBody(request.body);
36
+ if (!authenticate(request, reply, input.authenticator, body))
37
+ return;
38
+ prunePending(pending, now());
39
+ const accepted = input.evidence.acceptAuthenticated(body);
40
+ if (accepted.outcome !== "accepted")
41
+ return reply.code(422).send(accepted);
42
+ pending.set(accepted.evidenceReference.sourceEventId, {
43
+ reference: accepted.evidenceReference,
44
+ expiresAt: now() + AUTOMOD_SCANNER_LIMITS.evidenceDeadlineSeconds * 1_000,
45
+ });
46
+ return reply.code(202).send({ outcome: "accepted" });
47
+ });
48
+ server.post("/v1/ecko/results", { config: { rawBody: true } }, async (request, reply) => {
49
+ const body = rawBody(request.body);
50
+ if (!authenticate(request, reply, input.authenticator, body))
51
+ return;
52
+ if (body.byteLength > AUTOMOD_SCANNER_LIMITS.resultBytes)
53
+ return reply
54
+ .code(413)
55
+ .send({ outcome: "rejected", code: "invalid_envelope" });
56
+ const decoded = parseJson(body);
57
+ const gate = await input.gateState();
58
+ if (!gateAllowsResult(gate, decoded))
59
+ return reply
60
+ .code(503)
61
+ .send({ outcome: "rejected", code: "module_unavailable" });
62
+ const operationId = operationIdFrom(decoded);
63
+ prunePending(pending, now());
64
+ const evidence = operationId ? pending.get(operationId) : undefined;
65
+ if (operationId)
66
+ pending.delete(operationId);
67
+ return input.registry.acceptResult({
68
+ adapter: "hosted_ecko",
69
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
70
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
71
+ result: decoded,
72
+ ...(evidence ? { evidenceReference: evidence.reference } : {}),
73
+ });
74
+ });
75
+ server.post("/v1/ecko/telemetry", { config: { rawBody: true } }, async (request, reply) => {
76
+ const body = rawBody(request.body);
77
+ if (!authenticate(request, reply, input.authenticator, body))
78
+ return;
79
+ if (!input.telemetry)
80
+ return reply.code(503).send({ accepted: false });
81
+ const accepted = await input.telemetry.accept(parseJson(body));
82
+ return reply.code(accepted ? 202 : 409).send({ accepted });
83
+ });
84
+ }
85
+ function gateAllowsResult(gate, result) {
86
+ if (gate.state === "enabled")
87
+ return true;
88
+ if (gate.state === "disabled" || !result || typeof result !== "object")
89
+ return false;
90
+ const issuedAt = result["issuedAt"];
91
+ return (typeof issuedAt === "string" &&
92
+ Number.isFinite(Date.parse(issuedAt)) &&
93
+ Date.parse(issuedAt) <= gate.updatedAt.getTime());
94
+ }
95
+ function authenticate(request, reply, authenticator, body) {
96
+ const result = authenticator.authenticate({
97
+ method: request.method,
98
+ path: request.url,
99
+ headers: request.headers,
100
+ body,
101
+ });
102
+ if (result.authenticated)
103
+ return true;
104
+ reply
105
+ .code(result.code === "capacity" ? 429 : 401)
106
+ .send({ message: "Ecko transport authentication failed" });
107
+ return false;
108
+ }
109
+ function rawBody(value) {
110
+ if (value instanceof Uint8Array)
111
+ return value;
112
+ return Buffer.from(JSON.stringify(value ?? null));
113
+ }
114
+ function parseJson(body) {
115
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
116
+ }
117
+ function operationIdFrom(value) {
118
+ if (!value || typeof value !== "object")
119
+ return undefined;
120
+ const operationId = value["operationId"];
121
+ return typeof operationId === "string" && /^[a-f0-9]{64}$/u.test(operationId)
122
+ ? operationId
123
+ : undefined;
124
+ }
125
+ function prunePending(pending, now) {
126
+ for (const [operationId, evidence] of pending)
127
+ if (evidence.expiresAt <= now)
128
+ pending.delete(operationId);
129
+ }
130
+ //# sourceMappingURL=ecko-transport-routes.js.map
@@ -0,0 +1,46 @@
1
+ import type { CompiledEnhancedConfiguration, EnhancedScanResult } from "@helyx/module-automod/engine";
2
+ import type { EmbeddedWorkerObservation, EmbeddedWorkerRequest } from "./embedded-auto-moderation-worker-runtime.js";
3
+ interface WorkerLike {
4
+ postMessage(value: EmbeddedWorkerRequest): void;
5
+ terminate(): Promise<number>;
6
+ on(event: "message", listener: (value: unknown) => void): this;
7
+ on(event: "error", listener: (error: Error) => void): this;
8
+ on(event: "exit", listener: (code: number) => void): this;
9
+ }
10
+ export interface EmbeddedScannerPoolLimits {
11
+ workers: number;
12
+ maximumItems: number;
13
+ maximumBytes: number;
14
+ maximumItemsPerGuild: number;
15
+ }
16
+ export interface EmbeddedScannerPoolSnapshot {
17
+ status: "idle" | "ready" | "degraded" | "circuit_open" | "stopped";
18
+ workers: number;
19
+ configuredGuilds: number;
20
+ queuedItems: number;
21
+ queuedBytes: number;
22
+ activeItems: number;
23
+ overflow: number;
24
+ expired: number;
25
+ workerFailures: number;
26
+ }
27
+ export declare class EmbeddedAutoModerationPool {
28
+ #private;
29
+ private readonly limits;
30
+ constructor(limits: EmbeddedScannerPoolLimits, options?: {
31
+ workerFactory?: () => WorkerLike;
32
+ workerUrl?: URL;
33
+ now?: () => number;
34
+ });
35
+ private readonly now;
36
+ replaceConfigurations(configurations: readonly CompiledEnhancedConfiguration[]): void;
37
+ submit(input: {
38
+ observation: EmbeddedWorkerObservation;
39
+ deadlineAt: number;
40
+ complete: (result: EnhancedScanResult | null) => Promise<void>;
41
+ }): boolean;
42
+ snapshot(): EmbeddedScannerPoolSnapshot;
43
+ stop(timeoutMs?: number): Promise<boolean>;
44
+ }
45
+ export {};
46
+ //# sourceMappingURL=embedded-auto-moderation-pool.d.ts.map