@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
encryptPiiFieldValues,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
28
28
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
29
30
|
import { Temporal } from "temporal-polyfill";
|
|
30
31
|
import { z } from "zod";
|
|
31
32
|
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
|
|
@@ -99,6 +100,7 @@ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: strin
|
|
|
99
100
|
return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
|
|
102
104
|
export const ingestMessageHandler: WriteHandlerDef = {
|
|
103
105
|
name: "ingest-message",
|
|
104
106
|
schema: ingestMessageSchema,
|
|
@@ -221,46 +223,33 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// ---------------------------------------------------------------
|
|
224
|
-
// 5. Thread-Rollup.
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
226
|
+
// 5. Thread-Rollup. New snapshot (live COUNT + max(lastMessageAt)) via
|
|
227
|
+
// ctx.tryAppendEvent in a bounded retry loop — the apply stays a
|
|
228
|
+
// dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
|
|
229
|
+
// timestamp), so it stays plaintext-comparable; subject is
|
|
230
|
+
// encrypted once up front (constant across retries, doesn't
|
|
231
|
+
// depend on the thread's prior state).
|
|
230
232
|
//
|
|
231
|
-
// messageCount
|
|
232
|
-
// threadEvents
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
233
|
+
// messageCount deliberately does NOT come from a previously loaded
|
|
234
|
+
// threadEvents snapshot (previousCount+1): two concurrent ingests
|
|
235
|
+
// on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
|
|
236
|
+
// or two different messages of the same thread arriving at once)
|
|
237
|
+
// can both read the same stream version before either commits.
|
|
238
|
+
// tryAppendEvent's fresh version-read at append time then makes
|
|
239
|
+
// the loser's append fail with a VersionConflictError instead of
|
|
240
|
+
// silently succeeding — so we retry: reload the thread stream,
|
|
241
|
+
// recompute the live row-count and lastMessageAt, and append
|
|
242
|
+
// again. Unlike step 4, a lost race here must NOT be treated as a
|
|
243
|
+
// no-op duplicate — skipping would leave this message's
|
|
244
|
+
// contribution to messageCount/lastMessageAt out of the thread
|
|
245
|
+
// forever. Exhausting all attempts throws, rolling back the whole
|
|
246
|
+
// TX (including the step-4 message append) for a clean re-ingest
|
|
247
|
+
// on the next poll — never return success with a stale snapshot.
|
|
240
248
|
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// Drift theoretisch weiterhin auftreten). Was der COUNT aendert: er
|
|
245
|
-
// macht den Fehler SELBSTKORRIGIEREND statt kumulativ — jeder
|
|
246
|
-
// nachfolgende ingest liest den echten Row-Count neu, statt einen
|
|
247
|
-
// frueher falsch geschriebenen previousCount fortzuschreiben. Eine
|
|
248
|
-
// echte Race-freie Loesung braucht ein atomares Read+Append (z.B.
|
|
249
|
-
// SELECT ... FOR UPDATE oder eine Advisory-Lock auf threadAggId) —
|
|
250
|
-
// das ist bewusst nicht Teil dieses Fixes (siehe PR-Beschreibung).
|
|
249
|
+
// A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
|
|
250
|
+
// advisory lock on threadAggId) is deliberately not part of this
|
|
251
|
+
// fix — two writers (watch + poll) make bounded retry sufficient.
|
|
251
252
|
// ---------------------------------------------------------------
|
|
252
|
-
const threadEvents = await ctx.loadAggregate(threadAggId);
|
|
253
|
-
const lastThreadEvent = threadEvents[threadEvents.length - 1];
|
|
254
|
-
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
255
|
-
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
256
|
-
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
257
|
-
const newLastAt =
|
|
258
|
-
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
259
|
-
const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
|
|
260
|
-
tenantId,
|
|
261
|
-
threadKey,
|
|
262
|
-
});
|
|
263
|
-
|
|
264
253
|
const threadPlainPii = { tenantId, subject: payload.subject };
|
|
265
254
|
const encryptedThreadFields = piiKms
|
|
266
255
|
? await encryptPiiFieldValues(
|
|
@@ -275,19 +264,40 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
275
264
|
)
|
|
276
265
|
: threadPlainPii;
|
|
277
266
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
267
|
+
let threadAppendOk = false;
|
|
268
|
+
for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
|
|
269
|
+
const threadEvents = await ctx.loadAggregate(threadAggId);
|
|
270
|
+
const lastThreadEvent = threadEvents[threadEvents.length - 1];
|
|
271
|
+
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
272
|
+
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
273
|
+
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
274
|
+
const newLastAt =
|
|
275
|
+
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
276
|
+
const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
|
|
277
|
+
tenantId,
|
|
278
|
+
threadKey,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const threadEventPayload: MailThreadEventPayload = {
|
|
282
|
+
threadKey,
|
|
283
|
+
subject: encryptedThreadFields["subject"] as string,
|
|
284
|
+
lastMessageAtIso: newLastAt,
|
|
285
|
+
messageCount,
|
|
286
|
+
};
|
|
287
|
+
const threadAppend = await ctx.tryAppendEvent({
|
|
288
|
+
aggregateId: threadAggId,
|
|
289
|
+
aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
|
|
290
|
+
type: MAIL_THREAD_UPDATED_EVENT_QN,
|
|
291
|
+
payload: threadEventPayload,
|
|
292
|
+
headers: messageHeaders,
|
|
293
|
+
});
|
|
294
|
+
threadAppendOk = threadAppend.ok;
|
|
295
|
+
}
|
|
296
|
+
if (!threadAppendOk) {
|
|
297
|
+
throw new InternalError({
|
|
298
|
+
message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
291
301
|
|
|
292
302
|
// ---------------------------------------------------------------
|
|
293
303
|
// 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type MailAccountRecord,
|
|
26
26
|
type RawInboundMessage,
|
|
27
27
|
} from "../../inbound-mail-foundation";
|
|
28
|
+
import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
|
|
28
29
|
import { imapInboundMailPlugin } from "../feature";
|
|
29
30
|
|
|
30
31
|
const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
|
|
@@ -192,3 +193,14 @@ describe("imap-live — echter Server (greenmail)", () => {
|
|
|
192
193
|
20_000,
|
|
193
194
|
);
|
|
194
195
|
});
|
|
196
|
+
|
|
197
|
+
describeInboundMailProviderContract(
|
|
198
|
+
"imap (greenmail)",
|
|
199
|
+
() => ({
|
|
200
|
+
plugin: imapInboundMailPlugin,
|
|
201
|
+
ctx: ctxWithCredentials(credentialDoc(PASSWORD)),
|
|
202
|
+
account,
|
|
203
|
+
seed: (subject) => sendTestMail(subject, "contract seed"),
|
|
204
|
+
}),
|
|
205
|
+
{ skip: !available },
|
|
206
|
+
);
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Always-on unit coverage for imapInboundMailPlugin.verify/fetch/watch.
|
|
2
|
+
// Greenmail suites skip in CI when the container is down — this file mocks
|
|
3
|
+
// imapflow so the plugin body stays on the coverage badge without Docker.
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import {
|
|
9
|
+
type InboundMailContext,
|
|
10
|
+
isInboundAuthError,
|
|
11
|
+
isInboundCursorInvalidError,
|
|
12
|
+
isInboundTransientError,
|
|
13
|
+
type MailAccountRecord,
|
|
14
|
+
type RawInboundMessage,
|
|
15
|
+
} from "../../inbound-mail-foundation";
|
|
16
|
+
|
|
17
|
+
type FakeMsg = {
|
|
18
|
+
readonly uid: number;
|
|
19
|
+
readonly source: Buffer;
|
|
20
|
+
readonly internalDate: Date;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FakeState = {
|
|
24
|
+
connectError?: Error;
|
|
25
|
+
authFailed?: boolean;
|
|
26
|
+
uidValidity: string;
|
|
27
|
+
uidNext: number;
|
|
28
|
+
messages: FakeMsg[];
|
|
29
|
+
/** When set, search() returns this list instead of all message uids. */
|
|
30
|
+
searchUids?: number[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let state: FakeState;
|
|
34
|
+
let lastIdleClient: FakeImapFlow | undefined;
|
|
35
|
+
|
|
36
|
+
class FakeImapFlow extends EventEmitter {
|
|
37
|
+
mailbox: { uidValidity: bigint; uidNext: number } | false = false;
|
|
38
|
+
private idleReject: ((err: Error) => void) | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(_opts: unknown) {
|
|
41
|
+
super();
|
|
42
|
+
lastIdleClient = this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async connect(): Promise<void> {
|
|
46
|
+
if (state.connectError) {
|
|
47
|
+
const err = state.connectError as Error & { authenticationFailed?: boolean };
|
|
48
|
+
if (state.authFailed) err.authenticationFailed = true;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async logout(): Promise<void> {
|
|
54
|
+
this.idleReject?.(new Error("connection closed"));
|
|
55
|
+
this.idleReject = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getMailboxLock(_box: string): Promise<{ release: () => void }> {
|
|
59
|
+
this.mailbox = {
|
|
60
|
+
uidValidity: BigInt(state.uidValidity),
|
|
61
|
+
uidNext: state.uidNext,
|
|
62
|
+
};
|
|
63
|
+
return { release: () => {} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async mailboxOpen(_box: string): Promise<{ uidValidity: bigint; uidNext: number }> {
|
|
67
|
+
this.mailbox = {
|
|
68
|
+
uidValidity: BigInt(state.uidValidity),
|
|
69
|
+
uidNext: state.uidNext,
|
|
70
|
+
};
|
|
71
|
+
return this.mailbox;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async search(_query: unknown, _opts?: unknown): Promise<number[]> {
|
|
75
|
+
return state.searchUids ?? state.messages.map((m) => m.uid);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async *fetch(
|
|
79
|
+
range: string,
|
|
80
|
+
_opts?: unknown,
|
|
81
|
+
_uidOpts?: unknown,
|
|
82
|
+
): AsyncGenerator<{
|
|
83
|
+
uid: number;
|
|
84
|
+
source: Buffer;
|
|
85
|
+
internalDate: Date;
|
|
86
|
+
}> {
|
|
87
|
+
// Real imapflow is called with either "N,M,…" (explicit uid list) or "N:*".
|
|
88
|
+
const wanted = new Set<number>();
|
|
89
|
+
if (range.includes("*")) {
|
|
90
|
+
const start = Number(range.split(":")[0]);
|
|
91
|
+
for (const m of state.messages) {
|
|
92
|
+
if (m.uid >= start) wanted.add(m.uid);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
for (const part of range.split(",")) {
|
|
96
|
+
const n = Number(part.trim());
|
|
97
|
+
if (Number.isFinite(n)) wanted.add(n);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const m of state.messages) {
|
|
101
|
+
if (wanted.size > 0 && !wanted.has(m.uid)) continue;
|
|
102
|
+
yield { uid: m.uid, source: m.source, internalDate: m.internalDate };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async idle(): Promise<void> {
|
|
107
|
+
await new Promise<void>((_resolve, reject) => {
|
|
108
|
+
this.idleReject = reject;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
|
|
114
|
+
|
|
115
|
+
const { imapInboundMailPlugin } = await import("../feature");
|
|
116
|
+
|
|
117
|
+
function mime(subject: string, text: string): Buffer {
|
|
118
|
+
return Buffer.from(
|
|
119
|
+
[
|
|
120
|
+
"From: Sender <sender@example.com>",
|
|
121
|
+
"To: inbox@example.com",
|
|
122
|
+
`Subject: ${subject}`,
|
|
123
|
+
"Message-ID: <msg-1@example.com>",
|
|
124
|
+
"Date: Mon, 01 Jan 2024 12:00:00 +0000",
|
|
125
|
+
"MIME-Version: 1.0",
|
|
126
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
127
|
+
"",
|
|
128
|
+
text,
|
|
129
|
+
].join("\r\n"),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const account: MailAccountRecord = {
|
|
134
|
+
id: "00000000-0000-4000-8000-000000001a01",
|
|
135
|
+
tenantId: "00000000-0000-4000-8000-000000004242",
|
|
136
|
+
provider: "imap",
|
|
137
|
+
authMethod: "password",
|
|
138
|
+
ownerUserId: null,
|
|
139
|
+
address: "inbox@example.com",
|
|
140
|
+
displayName: "Mock",
|
|
141
|
+
status: "active",
|
|
142
|
+
watchState: "idle",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
function ctxWithDoc(doc: string | null): InboundMailContext {
|
|
146
|
+
return {
|
|
147
|
+
_userId: "imap-plugin-mocked",
|
|
148
|
+
secrets: {
|
|
149
|
+
get: async () => (doc === null ? null : createSecret(doc)),
|
|
150
|
+
has: async () => doc !== null,
|
|
151
|
+
set: async () => {},
|
|
152
|
+
delete: async () => {},
|
|
153
|
+
} as unknown as import("@cosmicdrift/kumiko-framework/secrets").SecretsContext, // @cast-boundary test-double
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const goodDoc = JSON.stringify({
|
|
158
|
+
host: "imap.example.com",
|
|
159
|
+
port: 993,
|
|
160
|
+
secure: true,
|
|
161
|
+
user: "u@example.com",
|
|
162
|
+
password: "pw",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
lastIdleClient = undefined;
|
|
167
|
+
state = {
|
|
168
|
+
uidValidity: "17",
|
|
169
|
+
uidNext: 3,
|
|
170
|
+
messages: [
|
|
171
|
+
{
|
|
172
|
+
uid: 1,
|
|
173
|
+
source: mime("one", "hello one"),
|
|
174
|
+
internalDate: new Date("2024-01-01T12:00:00Z"),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
uid: 2,
|
|
178
|
+
source: mime("two", "hello two"),
|
|
179
|
+
internalDate: new Date("2024-01-02T12:00:00Z"),
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
186
|
+
test("verify: connect+logout happy path", async () => {
|
|
187
|
+
await expect(
|
|
188
|
+
imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account),
|
|
189
|
+
).resolves.toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("verify: auth failure → InboundAuthError", async () => {
|
|
193
|
+
state.connectError = new Error("Authentication failed");
|
|
194
|
+
state.authFailed = true;
|
|
195
|
+
try {
|
|
196
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account);
|
|
197
|
+
expect.unreachable("expected verify to throw");
|
|
198
|
+
} catch (e) {
|
|
199
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("verify: missing credential secret → InboundAuthError", async () => {
|
|
204
|
+
try {
|
|
205
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(null), account);
|
|
206
|
+
expect.unreachable("expected verify to throw");
|
|
207
|
+
} catch (e) {
|
|
208
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("verify: malformed credential JSON → InboundAuthError", async () => {
|
|
213
|
+
try {
|
|
214
|
+
await imapInboundMailPlugin.verify(ctxWithDoc("not-json"), account);
|
|
215
|
+
expect.unreachable("expected verify to throw");
|
|
216
|
+
} catch (e) {
|
|
217
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("fetch: backfill (null cursor) returns messages + nextCursor", async () => {
|
|
222
|
+
const result = await imapInboundMailPlugin.fetch(ctxWithDoc(goodDoc), account, null, {
|
|
223
|
+
backfillWindowDays: 7,
|
|
224
|
+
maxMessages: 50,
|
|
225
|
+
});
|
|
226
|
+
expect(result.messages).toHaveLength(2);
|
|
227
|
+
expect(result.messages[0]?.subject).toBe("one");
|
|
228
|
+
expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 2 });
|
|
229
|
+
expect(result.hasMore).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("fetch: incremental cursor filters uid <= lastUid + hasMore via maxMessages", async () => {
|
|
233
|
+
state.searchUids = [1, 2, 3, 4];
|
|
234
|
+
state.messages = [
|
|
235
|
+
{
|
|
236
|
+
uid: 2,
|
|
237
|
+
source: mime("two", "x"),
|
|
238
|
+
internalDate: new Date("2024-01-02T12:00:00Z"),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
uid: 3,
|
|
242
|
+
source: mime("three", "y"),
|
|
243
|
+
internalDate: new Date("2024-01-03T12:00:00Z"),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
uid: 4,
|
|
247
|
+
source: mime("four", "z"),
|
|
248
|
+
internalDate: new Date("2024-01-04T12:00:00Z"),
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
const result = await imapInboundMailPlugin.fetch(
|
|
252
|
+
ctxWithDoc(goodDoc),
|
|
253
|
+
account,
|
|
254
|
+
{ uidValidity: "17", lastUid: 1 },
|
|
255
|
+
{ backfillWindowDays: 7, maxMessages: 2 },
|
|
256
|
+
);
|
|
257
|
+
// search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore
|
|
258
|
+
expect(result.messages.length).toBeLessThanOrEqual(2);
|
|
259
|
+
expect(result.hasMore).toBe(true);
|
|
260
|
+
expect(result.nextCursor["uidValidity"]).toBe("17");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("fetch: UIDVALIDITY change → InboundCursorInvalidError", async () => {
|
|
264
|
+
try {
|
|
265
|
+
await imapInboundMailPlugin.fetch(
|
|
266
|
+
ctxWithDoc(goodDoc),
|
|
267
|
+
account,
|
|
268
|
+
{ uidValidity: "16", lastUid: 1 },
|
|
269
|
+
{ backfillWindowDays: 7, maxMessages: 50 },
|
|
270
|
+
);
|
|
271
|
+
expect.unreachable("expected fetch to throw");
|
|
272
|
+
} catch (e) {
|
|
273
|
+
expect(isInboundCursorInvalidError(e)).toBe(true);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("fetch: network error → InboundTransientError", async () => {
|
|
278
|
+
state.connectError = new Error("ECONNREFUSED");
|
|
279
|
+
try {
|
|
280
|
+
await imapInboundMailPlugin.fetch(ctxWithDoc(goodDoc), account, null, {
|
|
281
|
+
backfillWindowDays: 7,
|
|
282
|
+
maxMessages: 50,
|
|
283
|
+
});
|
|
284
|
+
expect.unreachable("expected fetch to throw");
|
|
285
|
+
} catch (e) {
|
|
286
|
+
expect(isInboundTransientError(e)).toBe(true);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("watch: exists → onMessages, stop() tears down", async () => {
|
|
291
|
+
expect(imapInboundMailPlugin.watch).toBeDefined();
|
|
292
|
+
const received: RawInboundMessage[][] = [];
|
|
293
|
+
const stop = await imapInboundMailPlugin.watch!(ctxWithDoc(goodDoc), account, {
|
|
294
|
+
onMessages: async (msgs) => {
|
|
295
|
+
received.push([...msgs]);
|
|
296
|
+
},
|
|
297
|
+
onError: () => {},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// New mail above uidNext-1 (uidNext=3 → nextUid starts at 3)
|
|
301
|
+
state.messages = [
|
|
302
|
+
{
|
|
303
|
+
uid: 3,
|
|
304
|
+
source: mime("pushed", "idle body"),
|
|
305
|
+
internalDate: new Date("2024-01-05T12:00:00Z"),
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
lastIdleClient?.emit("exists");
|
|
309
|
+
|
|
310
|
+
await Bun.sleep(50);
|
|
311
|
+
expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
|
|
312
|
+
|
|
313
|
+
await stop();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("watch: client error → onError once", async () => {
|
|
317
|
+
let errors = 0;
|
|
318
|
+
const stop = await imapInboundMailPlugin.watch!(ctxWithDoc(goodDoc), account, {
|
|
319
|
+
onMessages: async () => {},
|
|
320
|
+
onError: () => {
|
|
321
|
+
errors += 1;
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
lastIdleClient?.emit("error", new Error("socket hang up"));
|
|
325
|
+
await Bun.sleep(20);
|
|
326
|
+
lastIdleClient?.emit("error", new Error("second"));
|
|
327
|
+
await Bun.sleep(20);
|
|
328
|
+
expect(errors).toBe(1);
|
|
329
|
+
await stop().catch(() => {});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
|
|
3
|
+
import {
|
|
4
|
+
inboundProviderInMemoryFeature,
|
|
5
|
+
inMemoryInboundMailPlugin,
|
|
6
|
+
seedInboundMessage,
|
|
7
|
+
} from "../feature";
|
|
8
|
+
|
|
9
|
+
describe("inboundProviderInMemoryFeature — shape", () => {
|
|
10
|
+
test("has the expected name + requirement", () => {
|
|
11
|
+
expect(inboundProviderInMemoryFeature.name).toBe("inbound-provider-inmemory");
|
|
12
|
+
expect(inboundProviderInMemoryFeature.requires).toContain("inbound-mail-foundation");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describeInboundMailProviderContract("inmemory", () => {
|
|
17
|
+
const accountId = crypto.randomUUID();
|
|
18
|
+
return {
|
|
19
|
+
plugin: inMemoryInboundMailPlugin,
|
|
20
|
+
ctx: {},
|
|
21
|
+
account: {
|
|
22
|
+
id: accountId,
|
|
23
|
+
tenantId: "00000000-0000-4000-8000-000000004242",
|
|
24
|
+
provider: "inmemory",
|
|
25
|
+
authMethod: "password",
|
|
26
|
+
ownerUserId: null,
|
|
27
|
+
address: "contract@example.com",
|
|
28
|
+
displayName: "Contract-Test",
|
|
29
|
+
status: "active",
|
|
30
|
+
watchState: "idle",
|
|
31
|
+
},
|
|
32
|
+
seed: (subject) =>
|
|
33
|
+
seedInboundMessage(accountId, {
|
|
34
|
+
providerMessageId: crypto.randomUUID(),
|
|
35
|
+
messageIdHeader: null,
|
|
36
|
+
providerThreadId: null,
|
|
37
|
+
references: [],
|
|
38
|
+
from: "sender@example.com",
|
|
39
|
+
to: ["contract@example.com"],
|
|
40
|
+
cc: [],
|
|
41
|
+
subject,
|
|
42
|
+
snippet: subject,
|
|
43
|
+
receivedAtIso: "2026-07-01T10:00:00Z",
|
|
44
|
+
rawMime: null,
|
|
45
|
+
scope: "inbox",
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
@@ -102,7 +102,7 @@ function offsetOf(cursor: SyncCursorPayload | null): number {
|
|
|
102
102
|
return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : 0;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const
|
|
105
|
+
export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
|
|
106
106
|
verify: async (_ctx, account: MailAccountRecord) => {
|
|
107
107
|
const state = stateFor(account.id);
|
|
108
108
|
if (state.nextVerifyError) {
|
|
@@ -150,5 +150,5 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
|
|
|
150
150
|
recommended: false,
|
|
151
151
|
});
|
|
152
152
|
r.requires("inbound-mail-foundation");
|
|
153
|
-
r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY,
|
|
153
|
+
r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
|
|
154
154
|
});
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
createEventStoreExecutor,
|
|
13
13
|
createTenantDb,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createEntity,
|
|
17
|
+
createTextField,
|
|
18
|
+
defineFeature,
|
|
19
|
+
type JobContext,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
21
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
22
|
import {
|
|
18
23
|
setupTestStack,
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
TestUsers,
|
|
21
26
|
unsafeCreateEntityTable,
|
|
22
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
29
|
import { createJobsFeature } from "../feature";
|
|
24
30
|
import { reindexEntityJob } from "../handlers/reindex-entity.job";
|
|
25
31
|
|
|
@@ -43,6 +49,16 @@ const widgetFeature = defineFeature("reindex-job-test", (r) => {
|
|
|
43
49
|
let stack: TestStack;
|
|
44
50
|
const admin = TestUsers.admin;
|
|
45
51
|
|
|
52
|
+
const noopLogger: JobContext["log"] = {
|
|
53
|
+
info() {},
|
|
54
|
+
warn() {},
|
|
55
|
+
error() {},
|
|
56
|
+
debug() {},
|
|
57
|
+
child() {
|
|
58
|
+
return noopLogger;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
46
62
|
beforeAll(async () => {
|
|
47
63
|
stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
|
|
48
64
|
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
@@ -87,6 +103,42 @@ describe("reindexEntityJob", () => {
|
|
|
87
103
|
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
88
104
|
});
|
|
89
105
|
|
|
106
|
+
test("the registered job handler resolves a systemUser-scoped fan-out run and actually indexes (not reindexEntityJob called directly)", async () => {
|
|
107
|
+
// The two direct-call tests above bypass jobRunner.dispatch()'s
|
|
108
|
+
// ctx-resolution/guard chain entirely — a bug there (e.g. a systemUser
|
|
109
|
+
// that isn't wired through) would be invisible to them. Invoke the
|
|
110
|
+
// ACTUAL registered handler, same as retention-cleanup's handler test.
|
|
111
|
+
const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
|
|
112
|
+
const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
|
|
113
|
+
const created = await executor.create({ name: "Registry-Resolved Widget" }, admin, tenantDb);
|
|
114
|
+
if (!created.isSuccess) throw new Error("seed failed");
|
|
115
|
+
|
|
116
|
+
const preResults = await stack.search.search(admin.tenantId, "registry-resolved", {
|
|
117
|
+
filterType: "widget",
|
|
118
|
+
});
|
|
119
|
+
expect(preResults).toHaveLength(0);
|
|
120
|
+
|
|
121
|
+
const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
|
|
122
|
+
expect(job).toBeDefined();
|
|
123
|
+
if (!job) return;
|
|
124
|
+
|
|
125
|
+
const ctx: JobContext = {
|
|
126
|
+
db: stack.db,
|
|
127
|
+
registry: stack.registry,
|
|
128
|
+
searchAdapter: stack.search,
|
|
129
|
+
systemUser: admin,
|
|
130
|
+
log: noopLogger,
|
|
131
|
+
triggeredBy: null,
|
|
132
|
+
...bridgeStub(),
|
|
133
|
+
};
|
|
134
|
+
await job.handler({ entity: "widget" }, ctx);
|
|
135
|
+
|
|
136
|
+
const postResults = await stack.search.search(admin.tenantId, "registry-resolved", {
|
|
137
|
+
filterType: "widget",
|
|
138
|
+
});
|
|
139
|
+
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
90
142
|
test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
|
|
91
143
|
await expect(
|
|
92
144
|
reindexEntityJob(
|
|
@@ -21,7 +21,7 @@ export const detailQuery = defineQueryHandler({
|
|
|
21
21
|
|
|
22
22
|
// payload is stored encrypted under the triggering user's DEK (#799).
|
|
23
23
|
if (typeof row["payload"] === "string") {
|
|
24
|
-
row["payload"] = await decryptStoredPii(row["payload"], "job-run-detail");
|
|
24
|
+
row["payload"] = await decryptStoredPii(row["payload"], "payload", "job-run-detail");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const logs = await selectMany(
|
|
@@ -6,7 +6,7 @@ import { jobRunsTable } from "../job-run-table";
|
|
|
6
6
|
|
|
7
7
|
async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
|
|
8
8
|
if (typeof row["payload"] !== "string") return row;
|
|
9
|
-
return { ...row, payload: await decryptStoredPii(row["payload"], "job-runs") };
|
|
9
|
+
return { ...row, payload: await decryptStoredPii(row["payload"], "payload", "job-runs") };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const listQuery = defineQueryHandler({
|