@takosjp/yurucommu-core 3.0.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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptography utilities for sensitive data encryption
|
|
3
|
+
*
|
|
4
|
+
* Uses AES-GCM for symmetric encryption of OAuth tokens and other sensitive data.
|
|
5
|
+
* The encryption key should be set via ENCRYPTION_KEY environment variable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from "./logger.ts";
|
|
9
|
+
import { bytesToHex } from "./hex.ts";
|
|
10
|
+
|
|
11
|
+
const log = logger.child({ component: "crypto" });
|
|
12
|
+
|
|
13
|
+
function isValidHexString(hex: string, expectedLength?: number): boolean {
|
|
14
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) return false;
|
|
15
|
+
if (hex.length % 2 !== 0) return false;
|
|
16
|
+
if (expectedLength !== undefined && hex.length !== expectedLength) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
23
|
+
if (!isValidHexString(hex)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Invalid hex string: must contain only hexadecimal characters (0-9, a-f, A-F) with even length",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
30
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
31
|
+
const byte = parseInt(hex.substring(i, i + 2), 16);
|
|
32
|
+
if (Number.isNaN(byte)) {
|
|
33
|
+
throw new Error(`Invalid hex character at position ${i}`);
|
|
34
|
+
}
|
|
35
|
+
bytes[i / 2] = byte;
|
|
36
|
+
}
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dev-only fallback salt used when YURUCOMMU_SESSION_HASH_SALT is unset.
|
|
42
|
+
* Mirrors the takosumi F7 pattern: fresh-DB tests still work, but a
|
|
43
|
+
* loud warning fires in strict / production mode so operators notice.
|
|
44
|
+
*/
|
|
45
|
+
const DEV_SESSION_HASH_SALT = "yurucommu:dev-only-session-hash-salt";
|
|
46
|
+
|
|
47
|
+
let warnedMissingSessionSalt = false;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hash a session id for at-rest storage / lookup. The persisted session-row
|
|
51
|
+
* key is `sha256:<hex>` of `salt:sessionId`; the raw session id only ever
|
|
52
|
+
* lives in the client cookie. A read-only leak of the session table therefore
|
|
53
|
+
* cannot be replayed against the API without also recovering the raw id.
|
|
54
|
+
*
|
|
55
|
+
* BREAKING CHANGE: rows written before this change stored the raw id as the
|
|
56
|
+
* key, so existing sessions no longer resolve and users must re-login. This is
|
|
57
|
+
* acceptable; truncating the sessions table has the same effect.
|
|
58
|
+
*
|
|
59
|
+
* `salt` should be the per-deployment YURUCOMMU_SESSION_HASH_SALT env value.
|
|
60
|
+
* When unset we fall back to a fixed dev salt and (in strict mode) warn.
|
|
61
|
+
*/
|
|
62
|
+
export async function hashSessionId(
|
|
63
|
+
sessionId: string,
|
|
64
|
+
salt: string | undefined,
|
|
65
|
+
strict = false,
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
let effectiveSalt = salt;
|
|
68
|
+
if (!effectiveSalt) {
|
|
69
|
+
if (strict && !warnedMissingSessionSalt) {
|
|
70
|
+
warnedMissingSessionSalt = true;
|
|
71
|
+
log.error(
|
|
72
|
+
"YURUCOMMU_SESSION_HASH_SALT is unset in strict/production mode; " +
|
|
73
|
+
"using an insecure shared dev fallback salt. Set a high-entropy " +
|
|
74
|
+
"value to make leaked session rows non-replayable.",
|
|
75
|
+
{ event: "crypto.session_salt.missing" },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
effectiveSalt = DEV_SESSION_HASH_SALT;
|
|
79
|
+
}
|
|
80
|
+
const digest = await crypto.subtle.digest(
|
|
81
|
+
"SHA-256",
|
|
82
|
+
new TextEncoder().encode(`${effectiveSalt}:${sessionId}`),
|
|
83
|
+
);
|
|
84
|
+
return `sha256:${bytesToHex(new Uint8Array(digest))}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the session-hash salt + strict flag from an Env-like object and hash
|
|
89
|
+
* the raw session id. Centralises the salt/strict derivation so every lookup
|
|
90
|
+
* and write site hashes identically.
|
|
91
|
+
*/
|
|
92
|
+
export function hashSessionIdForEnv(
|
|
93
|
+
env: {
|
|
94
|
+
YURUCOMMU_SESSION_HASH_SALT?: string;
|
|
95
|
+
YURUCOMMU_STRICT_READINESS?: string;
|
|
96
|
+
},
|
|
97
|
+
sessionId: string,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const strictValue = env.YURUCOMMU_STRICT_READINESS?.trim().toLowerCase();
|
|
100
|
+
const strict =
|
|
101
|
+
strictValue === "1" || strictValue === "true" || strictValue === "yes";
|
|
102
|
+
return hashSessionId(sessionId, env.YURUCOMMU_SESSION_HASH_SALT, strict);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class EncryptionKeyError extends Error {
|
|
106
|
+
override name = "EncryptionKeyError";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class DecryptionError extends Error {
|
|
110
|
+
override name = "DecryptionError";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function getEncryptionKey(
|
|
114
|
+
keyHex: string | undefined,
|
|
115
|
+
): Promise<CryptoKey | null> {
|
|
116
|
+
if (!keyHex) return null;
|
|
117
|
+
|
|
118
|
+
if (!isValidHexString(keyHex, 64)) {
|
|
119
|
+
log.error("Invalid encryption key format", {
|
|
120
|
+
event: "crypto.key.invalid_format",
|
|
121
|
+
reason: "must be exactly 64 hex characters (0-9, a-f, A-F)",
|
|
122
|
+
});
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const keyBytes = hexToBytes(keyHex);
|
|
128
|
+
if (keyBytes.byteLength !== 32) {
|
|
129
|
+
log.error("Invalid encryption key length", {
|
|
130
|
+
event: "crypto.key.invalid_length",
|
|
131
|
+
reason: "must decode to exactly 32 bytes",
|
|
132
|
+
actualLength: keyBytes.byteLength,
|
|
133
|
+
});
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return await crypto.subtle.importKey(
|
|
137
|
+
"raw",
|
|
138
|
+
keyBytes.buffer as ArrayBuffer,
|
|
139
|
+
{ name: "AES-GCM" },
|
|
140
|
+
false,
|
|
141
|
+
["encrypt", "decrypt"],
|
|
142
|
+
);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
log.error("Failed to import encryption key", {
|
|
145
|
+
event: "crypto.key.import_failed",
|
|
146
|
+
error,
|
|
147
|
+
});
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function requireEncryptionKey(
|
|
153
|
+
keyHex: string | undefined,
|
|
154
|
+
errorMessage: string,
|
|
155
|
+
): Promise<CryptoKey> {
|
|
156
|
+
const key = await getEncryptionKey(keyHex);
|
|
157
|
+
if (!key) throw new EncryptionKeyError(errorMessage);
|
|
158
|
+
return key;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Encrypt a string value using AES-GCM.
|
|
163
|
+
* Returns format: iv:ciphertext (both hex encoded)
|
|
164
|
+
*
|
|
165
|
+
* AAD NOTE (deferred, by design): this AES-GCM call binds no associated data, so
|
|
166
|
+
* a ciphertext is not cryptographically tied to the record it lives in. That
|
|
167
|
+
* would only matter for an attacker with DB WRITE access who relocates a
|
|
168
|
+
* ciphertext between rows — and today the only thing encrypted is the OAuth
|
|
169
|
+
* providerAccessToken/RefreshToken, which is WRITE-ONLY: `decrypt` has no callers
|
|
170
|
+
* and the stored token is merely presence-checked (`!!providerAccessToken`).
|
|
171
|
+
* AAD has no value until a decrypt-and-USE path exists; bind it THEN (to the
|
|
172
|
+
* member/session context that consumes the token) so the binding matches the
|
|
173
|
+
* use, rather than guessing the context now.
|
|
174
|
+
*/
|
|
175
|
+
export async function encrypt(
|
|
176
|
+
plaintext: string,
|
|
177
|
+
encryptionKey: string | undefined,
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
const key = await requireEncryptionKey(
|
|
180
|
+
encryptionKey,
|
|
181
|
+
"ENCRYPTION_KEY is not configured or invalid. " +
|
|
182
|
+
"A 32-byte (64 hex character) key is required to encrypt sensitive data. " +
|
|
183
|
+
"Generate one with: openssl rand -hex 32",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
187
|
+
const data = new TextEncoder().encode(plaintext);
|
|
188
|
+
|
|
189
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
190
|
+
{ name: "AES-GCM", iv: iv.buffer as ArrayBuffer },
|
|
191
|
+
key,
|
|
192
|
+
data.buffer as ArrayBuffer,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return `${bytesToHex(iv)}:${bytesToHex(new Uint8Array(ciphertext))}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Decrypt a string value encrypted with encrypt().
|
|
200
|
+
* Expects format: iv:ciphertext (both hex encoded)
|
|
201
|
+
*/
|
|
202
|
+
export async function decrypt(
|
|
203
|
+
encrypted: string,
|
|
204
|
+
encryptionKey: string | undefined,
|
|
205
|
+
): Promise<string> {
|
|
206
|
+
const key = await requireEncryptionKey(
|
|
207
|
+
encryptionKey,
|
|
208
|
+
"ENCRYPTION_KEY is not configured or invalid. " +
|
|
209
|
+
"Cannot decrypt data without the encryption key.",
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (!encrypted.includes(":")) {
|
|
213
|
+
throw new DecryptionError(
|
|
214
|
+
"Invalid encrypted data format. Please re-authenticate.",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const parts = encrypted.split(":");
|
|
219
|
+
if (parts.length !== 2) {
|
|
220
|
+
throw new DecryptionError(
|
|
221
|
+
"Invalid encrypted data format. Please re-authenticate.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const [ivHex, ciphertextHex] = parts;
|
|
226
|
+
if (!ivHex || !ciphertextHex) {
|
|
227
|
+
throw new DecryptionError(
|
|
228
|
+
"Invalid encrypted data format. Please re-authenticate.",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (!isValidHexString(ivHex, 24)) {
|
|
232
|
+
throw new DecryptionError(
|
|
233
|
+
"Invalid encrypted data format. Please re-authenticate.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (!isValidHexString(ciphertextHex) || ciphertextHex.length < 32) {
|
|
237
|
+
throw new DecryptionError(
|
|
238
|
+
"Invalid encrypted data format. Please re-authenticate.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const iv = hexToBytes(ivHex);
|
|
244
|
+
const ciphertext = hexToBytes(ciphertextHex);
|
|
245
|
+
|
|
246
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
247
|
+
{ name: "AES-GCM", iv: iv.buffer as ArrayBuffer },
|
|
248
|
+
key,
|
|
249
|
+
ciphertext.buffer as ArrayBuffer,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return new TextDecoder().decode(decrypted);
|
|
253
|
+
} catch {
|
|
254
|
+
throw new DecryptionError(
|
|
255
|
+
"Failed to decrypt data. The encryption key may be incorrect or the data is corrupted.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// PBKDF2 password hashing
|
|
261
|
+
|
|
262
|
+
const PBKDF2_ITERATIONS = 100000; // Cloudflare Workers caps deriveBits at 100k iterations
|
|
263
|
+
const SALT_LENGTH = 32;
|
|
264
|
+
const HASH_LENGTH = 32;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Derive bits from a password and salt using PBKDF2-SHA256.
|
|
268
|
+
*/
|
|
269
|
+
async function derivePasswordBits(
|
|
270
|
+
password: string,
|
|
271
|
+
salt: Uint8Array,
|
|
272
|
+
hashLengthBytes: number,
|
|
273
|
+
): Promise<Uint8Array> {
|
|
274
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
275
|
+
"raw",
|
|
276
|
+
new TextEncoder().encode(password).buffer as ArrayBuffer,
|
|
277
|
+
"PBKDF2",
|
|
278
|
+
false,
|
|
279
|
+
["deriveBits"],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
283
|
+
{
|
|
284
|
+
name: "PBKDF2",
|
|
285
|
+
salt: salt.buffer as ArrayBuffer,
|
|
286
|
+
iterations: PBKDF2_ITERATIONS,
|
|
287
|
+
hash: "SHA-256",
|
|
288
|
+
},
|
|
289
|
+
keyMaterial,
|
|
290
|
+
hashLengthBytes * 8,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return new Uint8Array(derivedBits);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Constant-time comparison of two byte arrays.
|
|
298
|
+
*/
|
|
299
|
+
function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
300
|
+
if (a.length !== b.length) return false;
|
|
301
|
+
let result = 0;
|
|
302
|
+
for (let i = 0; i < a.length; i++) {
|
|
303
|
+
result |= a[i] ^ b[i];
|
|
304
|
+
}
|
|
305
|
+
return result === 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Hash a password using PBKDF2-SHA256.
|
|
310
|
+
* Returns format: salt:hash (both hex encoded)
|
|
311
|
+
*/
|
|
312
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
313
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
314
|
+
const hash = await derivePasswordBits(password, salt, HASH_LENGTH);
|
|
315
|
+
return `${bytesToHex(salt)}:${bytesToHex(hash)}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Verify a password against a stored hash (salt:hash, hex encoded).
|
|
320
|
+
*/
|
|
321
|
+
export async function verifyPassword(
|
|
322
|
+
password: string,
|
|
323
|
+
storedHash: string,
|
|
324
|
+
): Promise<boolean> {
|
|
325
|
+
if (!storedHash.includes(":")) return false;
|
|
326
|
+
|
|
327
|
+
const [saltHex, expectedHashHex] = storedHash.split(":");
|
|
328
|
+
if (!saltHex || !expectedHashHex) return false;
|
|
329
|
+
if (!isValidHexString(saltHex) || !isValidHexString(expectedHashHex)) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const salt = hexToBytes(saltHex);
|
|
335
|
+
const expectedHash = hexToBytes(expectedHashHex);
|
|
336
|
+
const computedHash = await derivePasswordBits(
|
|
337
|
+
password,
|
|
338
|
+
salt,
|
|
339
|
+
expectedHash.length,
|
|
340
|
+
);
|
|
341
|
+
return timingSafeEqual(computedHash, expectedHash);
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Decide whether a stored AUTH_PASSWORD_HASH value is a proper PBKDF2
|
|
349
|
+
* `salt:hash` credential or a bootstrap shared secret.
|
|
350
|
+
*
|
|
351
|
+
* A fresh Capsule install generates AUTH_PASSWORD_HASH as a colon-less
|
|
352
|
+
* 64-char hex token (enough to satisfy the readiness AUTH_METHOD gate) rather
|
|
353
|
+
* than a real PBKDF2 hash. The PBKDF2 form is `<saltHex>:<hashHex>` (two
|
|
354
|
+
* hex segments separated by exactly one ':'). Anything that is not in that
|
|
355
|
+
* shape is treated as a bootstrap shared secret the operator types in as the
|
|
356
|
+
* password.
|
|
357
|
+
*/
|
|
358
|
+
function isPbkdf2Hash(storedHash: string): boolean {
|
|
359
|
+
const parts = storedHash.split(":");
|
|
360
|
+
if (parts.length !== 2) return false;
|
|
361
|
+
const [saltHex, hashHex] = parts;
|
|
362
|
+
if (!saltHex || !hashHex) return false;
|
|
363
|
+
return isValidHexString(saltHex) && isValidHexString(hashHex);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let warnedBootstrapCredential = false;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Verify a submitted password against the configured AUTH_PASSWORD_HASH.
|
|
370
|
+
*
|
|
371
|
+
* - If the stored value is a proper PBKDF2 `salt:hash` credential, verify it
|
|
372
|
+
* with the normal PBKDF2 path (unchanged behaviour).
|
|
373
|
+
* - Otherwise the stored value is a bootstrap shared secret (e.g. the
|
|
374
|
+
* colon-less hex token a fresh Capsule install generates). The operator
|
|
375
|
+
* logs in by entering that token verbatim as the password; it is compared
|
|
376
|
+
* in constant time so a fresh install is actually loginnable while avoiding
|
|
377
|
+
* timing leaks. A one-time warning recommends setting a real password.
|
|
378
|
+
*/
|
|
379
|
+
export async function verifyBootstrapOrPassword(
|
|
380
|
+
password: string,
|
|
381
|
+
storedHash: string,
|
|
382
|
+
): Promise<boolean> {
|
|
383
|
+
if (isPbkdf2Hash(storedHash)) {
|
|
384
|
+
return verifyPassword(password, storedHash);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!warnedBootstrapCredential) {
|
|
388
|
+
warnedBootstrapCredential = true;
|
|
389
|
+
log.warn(
|
|
390
|
+
"AUTH_PASSWORD_HASH is a bootstrap shared secret, not a PBKDF2 " +
|
|
391
|
+
"salt:hash credential. Login is accepted by entering that token " +
|
|
392
|
+
"verbatim as the password. Set a real password (hashPassword) to " +
|
|
393
|
+
"replace this bootstrap credential.",
|
|
394
|
+
{ event: "crypto.password.bootstrap_credential" },
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Constant-time compare over the raw UTF-8 bytes of the token. The byte
|
|
399
|
+
// arrays are unequal length when the token does not match, in which case
|
|
400
|
+
// timingSafeEqual still does length-independent work via a fixed-length
|
|
401
|
+
// comparison against the stored secret.
|
|
402
|
+
const stored = new TextEncoder().encode(storedHash);
|
|
403
|
+
const candidate = new TextEncoder().encode(password);
|
|
404
|
+
return timingSafeEqualConstantTime(candidate, stored);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Length-independent constant-time comparison: always iterates over the
|
|
409
|
+
* stored secret's length so the running time does not reveal whether the
|
|
410
|
+
* candidate matched or even how long it was.
|
|
411
|
+
*/
|
|
412
|
+
function timingSafeEqualConstantTime(
|
|
413
|
+
candidate: Uint8Array,
|
|
414
|
+
secret: Uint8Array,
|
|
415
|
+
): boolean {
|
|
416
|
+
let diff = candidate.length ^ secret.length;
|
|
417
|
+
for (let i = 0; i < secret.length; i++) {
|
|
418
|
+
// When candidate is shorter, fold in a non-matching byte so the loop is
|
|
419
|
+
// still secret-length and never short-circuits.
|
|
420
|
+
const c = i < candidate.length ? candidate[i] : secret[i] ^ 0xff;
|
|
421
|
+
diff |= c ^ secret[i];
|
|
422
|
+
}
|
|
423
|
+
return diff === 0;
|
|
424
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Database } from "../../../db/index.ts";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { deliveryCircuit } from "../../../db/index.ts";
|
|
4
|
+
import { safeParseIsoTimeMs } from "./transformers.ts";
|
|
5
|
+
import { logger } from "../logger.ts";
|
|
6
|
+
|
|
7
|
+
const log = logger.child({ component: "delivery.circuit" });
|
|
8
|
+
|
|
9
|
+
export type CircuitState = "closed" | "open" | "half_open";
|
|
10
|
+
|
|
11
|
+
const OPEN_DURATION_MS = 5 * 60 * 1000;
|
|
12
|
+
const HALF_OPEN_PROBES = 3;
|
|
13
|
+
const HALF_OPEN_DEFER_SECONDS = 30;
|
|
14
|
+
const RECENT_WINDOW_SIZE = 20;
|
|
15
|
+
const CONSECUTIVE_FAILURE_THRESHOLD = 5;
|
|
16
|
+
const FAILURE_RATE_THRESHOLD = 0.6;
|
|
17
|
+
|
|
18
|
+
type CircuitRow = {
|
|
19
|
+
endpoint: string;
|
|
20
|
+
state: CircuitState;
|
|
21
|
+
consecutiveFailures: number;
|
|
22
|
+
recentOutcomesJson: string; // JSON array of 0(success)/1(failure)
|
|
23
|
+
openUntil: string | null;
|
|
24
|
+
halfOpenProbeAttempts: number;
|
|
25
|
+
halfOpenProbeSuccesses: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type CircuitData = Partial<Omit<CircuitRow, "endpoint">>;
|
|
29
|
+
|
|
30
|
+
const INITIAL_CIRCUIT_DATA: CircuitData = {
|
|
31
|
+
state: "closed",
|
|
32
|
+
consecutiveFailures: 0,
|
|
33
|
+
recentOutcomesJson: "[]",
|
|
34
|
+
openUntil: null,
|
|
35
|
+
halfOpenProbeAttempts: 0,
|
|
36
|
+
halfOpenProbeSuccesses: 0,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function parseRecentOutcomes(json: string): number[] {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(json) as unknown;
|
|
42
|
+
if (!Array.isArray(parsed)) return [];
|
|
43
|
+
return parsed.map((v) => (v === 1 ? 1 : 0)).slice(-RECENT_WINDOW_SIZE);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serializeRecentOutcomes(values: number[]): string {
|
|
50
|
+
return JSON.stringify(values.slice(-RECENT_WINDOW_SIZE));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loads the circuit row for an endpoint and appends an outcome (0=success, 1=failure).
|
|
55
|
+
* Returns the row and the updated recent-outcomes array ready for persistence.
|
|
56
|
+
*/
|
|
57
|
+
async function loadCircuitWithOutcome(
|
|
58
|
+
db: Database,
|
|
59
|
+
endpoint: string,
|
|
60
|
+
outcome: 0 | 1,
|
|
61
|
+
): Promise<{ circuit: CircuitRow; recent: number[] }> {
|
|
62
|
+
const circuit = await getOrCreateCircuit(db, endpoint);
|
|
63
|
+
const recent = parseRecentOutcomes(circuit.recentOutcomesJson);
|
|
64
|
+
recent.push(outcome);
|
|
65
|
+
return { circuit, recent };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildOpenData(
|
|
69
|
+
recent: number[],
|
|
70
|
+
consecutiveFailures: number,
|
|
71
|
+
): CircuitData {
|
|
72
|
+
return {
|
|
73
|
+
state: "open",
|
|
74
|
+
consecutiveFailures,
|
|
75
|
+
recentOutcomesJson: serializeRecentOutcomes(recent),
|
|
76
|
+
openUntil: new Date(Date.now() + OPEN_DURATION_MS).toISOString(),
|
|
77
|
+
halfOpenProbeAttempts: 0,
|
|
78
|
+
halfOpenProbeSuccesses: 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function updateCircuit(
|
|
83
|
+
db: Database,
|
|
84
|
+
endpoint: string,
|
|
85
|
+
data: CircuitData,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
await db
|
|
88
|
+
.update(deliveryCircuit)
|
|
89
|
+
.set(data)
|
|
90
|
+
.where(eq(deliveryCircuit.endpoint, endpoint));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function getOrCreateCircuit(
|
|
94
|
+
db: Database,
|
|
95
|
+
endpoint: string,
|
|
96
|
+
): Promise<CircuitRow> {
|
|
97
|
+
const columns = {
|
|
98
|
+
endpoint: deliveryCircuit.endpoint,
|
|
99
|
+
state: deliveryCircuit.state,
|
|
100
|
+
consecutiveFailures: deliveryCircuit.consecutiveFailures,
|
|
101
|
+
recentOutcomesJson: deliveryCircuit.recentOutcomesJson,
|
|
102
|
+
openUntil: deliveryCircuit.openUntil,
|
|
103
|
+
halfOpenProbeAttempts: deliveryCircuit.halfOpenProbeAttempts,
|
|
104
|
+
halfOpenProbeSuccesses: deliveryCircuit.halfOpenProbeSuccesses,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const existing = await db
|
|
108
|
+
.select(columns)
|
|
109
|
+
.from(deliveryCircuit)
|
|
110
|
+
.where(eq(deliveryCircuit.endpoint, endpoint))
|
|
111
|
+
.get();
|
|
112
|
+
if (existing) return existing as CircuitRow;
|
|
113
|
+
|
|
114
|
+
// Race-safe create. checkCircuit() calls this BEFORE the per-host bulkhead is
|
|
115
|
+
// acquired, and deliver_endpoint messages run concurrently (Cloudflare Queues
|
|
116
|
+
// is at-least-once), so two deliveries to the SAME never-seen endpoint can both
|
|
117
|
+
// pass the SELECT and reach this INSERT. `endpoint` is the PRIMARY KEY, so the
|
|
118
|
+
// second bare INSERT would throw a UNIQUE/PK violation and force a 60s message
|
|
119
|
+
// redelivery. onConflictDoNothing makes it race-safe; if the conflict swallowed
|
|
120
|
+
// our row (returning() empty), re-select the row the winner created. (Mirrors
|
|
121
|
+
// the actor_cache cold-insert discipline.)
|
|
122
|
+
const created = await db
|
|
123
|
+
.insert(deliveryCircuit)
|
|
124
|
+
.values({ endpoint, ...INITIAL_CIRCUIT_DATA })
|
|
125
|
+
.onConflictDoNothing()
|
|
126
|
+
.returning(columns)
|
|
127
|
+
.get();
|
|
128
|
+
if (created) return created as CircuitRow;
|
|
129
|
+
|
|
130
|
+
const winner = await db
|
|
131
|
+
.select(columns)
|
|
132
|
+
.from(deliveryCircuit)
|
|
133
|
+
.where(eq(deliveryCircuit.endpoint, endpoint))
|
|
134
|
+
.get();
|
|
135
|
+
return winner as CircuitRow;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function checkCircuit(
|
|
139
|
+
db: Database,
|
|
140
|
+
endpoint: string,
|
|
141
|
+
): Promise<{ allow: true } | { allow: false; deferSeconds: number }> {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const circuit = await getOrCreateCircuit(db, endpoint);
|
|
144
|
+
|
|
145
|
+
if (circuit.state === "open") {
|
|
146
|
+
const untilMs = safeParseIsoTimeMs(circuit.openUntil);
|
|
147
|
+
if (untilMs !== null && now < untilMs) {
|
|
148
|
+
const deferSeconds = Math.max(1, Math.ceil((untilMs - now) / 1000));
|
|
149
|
+
return { allow: false, deferSeconds };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Transition to half-open when open window elapsed.
|
|
153
|
+
await updateCircuit(db, endpoint, {
|
|
154
|
+
state: "half_open",
|
|
155
|
+
openUntil: null,
|
|
156
|
+
halfOpenProbeAttempts: 0,
|
|
157
|
+
halfOpenProbeSuccesses: 0,
|
|
158
|
+
});
|
|
159
|
+
return { allow: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
circuit.state === "half_open" &&
|
|
164
|
+
circuit.halfOpenProbeAttempts >= HALF_OPEN_PROBES
|
|
165
|
+
) {
|
|
166
|
+
return { allow: false, deferSeconds: HALF_OPEN_DEFER_SECONDS };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { allow: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function recordCircuitSuccess(
|
|
173
|
+
db: Database,
|
|
174
|
+
endpoint: string,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const { circuit, recent } = await loadCircuitWithOutcome(db, endpoint, 0);
|
|
177
|
+
const serialized = serializeRecentOutcomes(recent);
|
|
178
|
+
|
|
179
|
+
if (circuit.state === "half_open") {
|
|
180
|
+
const nextAttempts = circuit.halfOpenProbeAttempts + 1;
|
|
181
|
+
const nextSuccesses = circuit.halfOpenProbeSuccesses + 1;
|
|
182
|
+
const allProbesSucceeded =
|
|
183
|
+
nextAttempts >= HALF_OPEN_PROBES && nextSuccesses >= HALF_OPEN_PROBES;
|
|
184
|
+
|
|
185
|
+
await updateCircuit(
|
|
186
|
+
db,
|
|
187
|
+
endpoint,
|
|
188
|
+
allProbesSucceeded
|
|
189
|
+
? { ...INITIAL_CIRCUIT_DATA, recentOutcomesJson: serialized }
|
|
190
|
+
: {
|
|
191
|
+
consecutiveFailures: 0,
|
|
192
|
+
recentOutcomesJson: serialized,
|
|
193
|
+
halfOpenProbeAttempts: nextAttempts,
|
|
194
|
+
halfOpenProbeSuccesses: nextSuccesses,
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await updateCircuit(db, endpoint, {
|
|
201
|
+
consecutiveFailures: 0,
|
|
202
|
+
recentOutcomesJson: serialized,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// CONCURRENCY NOTE (accepted, bounded): the circuit state (counter + recent-
|
|
207
|
+
// outcome window + state machine) is read here and written via a blind UPDATE in
|
|
208
|
+
// updateCircuit, so two concurrent deliveries to the SAME endpoint can lose one
|
|
209
|
+
// of their increments / window samples (last-writer-wins). This is deliberately
|
|
210
|
+
// NOT made strongly consistent: it is a delivery THROTTLE heuristic, not a
|
|
211
|
+
// correctness/security invariant — the only effect of a lost increment is the
|
|
212
|
+
// breaker opening a couple of failures later than CONSECUTIVE_FAILURE_THRESHOLD,
|
|
213
|
+
// costing a few extra attempts to an already-failing host. The per-host bulkhead
|
|
214
|
+
// (BULKHEAD_PER_DOMAIN) bounds the concurrency. A strongly-consistent version
|
|
215
|
+
// would need a Durable Object (single-threaded) or a CAS+retry loop on this hot
|
|
216
|
+
// path — disproportionate for a throttle, so it is left best-effort by design.
|
|
217
|
+
export async function recordCircuitFailure(
|
|
218
|
+
db: Database,
|
|
219
|
+
endpoint: string,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const { circuit, recent } = await loadCircuitWithOutcome(db, endpoint, 1);
|
|
222
|
+
|
|
223
|
+
// Half-open failure: immediately re-open.
|
|
224
|
+
if (circuit.state === "half_open") {
|
|
225
|
+
await updateCircuit(
|
|
226
|
+
db,
|
|
227
|
+
endpoint,
|
|
228
|
+
buildOpenData(recent, CONSECUTIVE_FAILURE_THRESHOLD),
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const consecutiveFailures = circuit.consecutiveFailures + 1;
|
|
234
|
+
const window = recent.slice(-RECENT_WINDOW_SIZE);
|
|
235
|
+
const failures = window.reduce((sum, v) => sum + v, 0);
|
|
236
|
+
const fullWindow = window.length === RECENT_WINDOW_SIZE;
|
|
237
|
+
|
|
238
|
+
// Open conditions (contract):
|
|
239
|
+
// - N consecutive failures OR
|
|
240
|
+
// - failure rate >= threshold over a full recent window.
|
|
241
|
+
const shouldOpen =
|
|
242
|
+
consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD ||
|
|
243
|
+
(fullWindow && failures / RECENT_WINDOW_SIZE >= FAILURE_RATE_THRESHOLD);
|
|
244
|
+
|
|
245
|
+
if (shouldOpen) {
|
|
246
|
+
await updateCircuit(
|
|
247
|
+
db,
|
|
248
|
+
endpoint,
|
|
249
|
+
buildOpenData(recent, consecutiveFailures),
|
|
250
|
+
);
|
|
251
|
+
log.warn("Circuit opened", {
|
|
252
|
+
event: "delivery.circuit.opened",
|
|
253
|
+
endpoint,
|
|
254
|
+
consecutiveFailures,
|
|
255
|
+
failures,
|
|
256
|
+
windowSize: window.length,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await updateCircuit(db, endpoint, {
|
|
262
|
+
consecutiveFailures,
|
|
263
|
+
recentOutcomesJson: serializeRecentOutcomes(recent),
|
|
264
|
+
});
|
|
265
|
+
}
|