@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
|
@@ -33,6 +33,10 @@ const FEATURE_NAME = "file-derivatives";
|
|
|
33
33
|
// a name list here "for safety" — see #1985.
|
|
34
34
|
const VARIANT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
35
35
|
|
|
36
|
+
/** Host → tenantId for the anonymous media route.
|
|
37
|
+
* MUST be cheap/synchronous (in-memory map or cached lookup): it runs
|
|
38
|
+
* *before* the query-handler rate limit, so a DB round-trip here is an
|
|
39
|
+
* unthrottled DoS surface for anonymous callers rotating Host headers (#1977). */
|
|
36
40
|
export type PublicVariantResolveApexTenant = (
|
|
37
41
|
host: string,
|
|
38
42
|
) => Promise<TenantId | null> | TenantId | null;
|
|
@@ -42,7 +46,8 @@ export type FileDerivativesOptions = {
|
|
|
42
46
|
* Without this option, neither the httpRoute NOR the `publicVariant`
|
|
43
47
|
* query is registered — it is unreachable via the httpRoute path and via
|
|
44
48
|
* the generic `/api` query dispatch. Existing consumers that only need
|
|
45
|
-
* `ctx.derivatives` are unaffected.
|
|
49
|
+
* `ctx.derivatives` are unaffected. See {@link PublicVariantResolveApexTenant}
|
|
50
|
+
* for the in-memory/sync contract. */
|
|
46
51
|
readonly resolveApexTenant?: PublicVariantResolveApexTenant;
|
|
47
52
|
/** Base path of the public variant route. Default "/media". */
|
|
48
53
|
readonly basePath?: string;
|
|
@@ -112,12 +117,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
112
117
|
// dispatch even when the httpRoute below isn't mounted, bypassing the
|
|
113
118
|
// "tenantId comes from the host, never the payload" invariant that only
|
|
114
119
|
// `resolveApexTenant` enforces.
|
|
115
|
-
const queries: { publicVariant?: ReturnType<typeof r.queryHandler> } = {};
|
|
116
|
-
|
|
117
120
|
if (opts.resolveApexTenant) {
|
|
118
121
|
const resolveApexTenant = opts.resolveApexTenant;
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
r.queryHandler(publicVariantQuery);
|
|
121
124
|
|
|
122
125
|
r.httpRoute({
|
|
123
126
|
method: "GET",
|
|
@@ -180,8 +183,6 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
180
183
|
},
|
|
181
184
|
});
|
|
182
185
|
}
|
|
183
|
-
|
|
184
|
-
return { queries };
|
|
185
186
|
});
|
|
186
187
|
}
|
|
187
188
|
|
|
@@ -52,9 +52,8 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
52
52
|
r.requires("file-foundation");
|
|
53
53
|
|
|
54
54
|
const secretAccessKey = r.secret("s3.secretAccessKey", {
|
|
55
|
-
label: {
|
|
55
|
+
label: { en: "S3 Secret Access Key" },
|
|
56
56
|
hint: {
|
|
57
|
-
de: "Privater Teil des S3-Schlüsselpaares. Bei Hetzner Object Storage 'Secret Key', bei AWS S3 'Secret Access Key'.",
|
|
58
57
|
en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
|
|
59
58
|
},
|
|
60
59
|
redact: (plaintext) => {
|
|
@@ -165,15 +165,14 @@ async function dispatchCleanup(): Promise<void> {
|
|
|
165
165
|
|
|
166
166
|
describe("form-draft cleanup job", () => {
|
|
167
167
|
// FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY (cleanup.job.ts) is a hardcoded
|
|
168
|
-
// literal that must match
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
expect(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY).toBe(
|
|
175
|
-
qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab("retentionDays")),
|
|
168
|
+
// literal that must match a key actually registered by formDraftFeature.
|
|
169
|
+
// Assert against the feature's configKeys (not a hand-rebuilt qn string),
|
|
170
|
+
// so renaming the key in feature.ts without updating the constant fails.
|
|
171
|
+
test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY is among formDraftFeature registered config keys", () => {
|
|
172
|
+
const registered = Object.keys(formDraftFeature.configKeys ?? {}).map((key) =>
|
|
173
|
+
qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab(key)),
|
|
176
174
|
);
|
|
175
|
+
expect(registered).toContain(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY);
|
|
177
176
|
});
|
|
178
177
|
|
|
179
178
|
test("deletes a draft older than the default retention window, leaves a fresh one alone", async () => {
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// Chunked DELETE (default 1000/batch) keeps lock durations bounded, mirror
|
|
7
7
|
// of sessions/handlers/cleanup.job.ts.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createEventStoreExecutor,
|
|
11
|
+
createTenantDb,
|
|
12
|
+
type DbConnection,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
10
14
|
import {
|
|
11
15
|
type AppContext,
|
|
12
16
|
access,
|
|
@@ -19,11 +23,16 @@ import {
|
|
|
19
23
|
type TenantId,
|
|
20
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
25
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
26
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
22
27
|
import { type StaleDraftRow, selectStaleDraftsBatch } from "../db/queries/cleanup";
|
|
23
|
-
import {
|
|
28
|
+
import { filterOwnedFileRefs } from "../db/queries/owned-file-refs";
|
|
24
29
|
import { formDraftExecutor } from "../executor";
|
|
25
30
|
import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
|
|
26
31
|
|
|
32
|
+
const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
33
|
+
entityName: "fileRef",
|
|
34
|
+
});
|
|
35
|
+
|
|
27
36
|
export const FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY = "form-draft:config:retention-days";
|
|
28
37
|
export const FORM_DRAFT_DEFAULT_RETENTION_DAYS = 30;
|
|
29
38
|
const DEFAULT_BATCH_SIZE = 1000;
|
|
@@ -48,27 +57,15 @@ async function releaseRowFileRefs(
|
|
|
48
57
|
// skip: no FileRefs in this row's blob — nothing to release.
|
|
49
58
|
if (keys.length === 0) return;
|
|
50
59
|
if (!fileProviderResolver) {
|
|
51
|
-
// skip: no resolver wired (files feature not mounted) — row still
|
|
52
|
-
// gets deleted below, but its FileRefs leak as storage orphans.
|
|
53
60
|
log?.warn?.(
|
|
54
61
|
`[form-draft:cleanup] tenant=${row.tenantId} has ${keys.length} FileRef(s) but no _fileProviderResolver is wired — row will be deleted without releasing storage`,
|
|
55
62
|
);
|
|
56
|
-
// skip:
|
|
63
|
+
// skip: no provider wired — cannot release storage; row deletion proceeds anyway.
|
|
57
64
|
return;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
// Only storageKeys with a real file_refs row owned by this row's draft
|
|
61
|
-
// owner are releasable — `draft.values` is free-form JSON the owning user
|
|
62
|
-
// controls, so an unverified key could target someone else's file (see
|
|
63
|
-
// db/queries/owned-file-refs.ts). Not wrapped in try/catch: a query
|
|
64
|
-
// failure here (pool exhaustion, missing table) is a real error, not the
|
|
65
|
-
// "no provider resolvable" case below — let it propagate so the job retries.
|
|
66
|
-
//
|
|
67
|
-
// Create-mode draftKey (`${screenId}:new:${draftId}`) mints its draftId
|
|
68
|
-
// lazily on the first step-change — a file uploaded before that exists
|
|
69
|
-
// predates the draft row, so the insertedAt filter must not apply.
|
|
70
67
|
const isCreateMode = row.draftKey.includes(":new:");
|
|
71
|
-
const
|
|
68
|
+
const ownedRefs = await filterOwnedFileRefs(
|
|
72
69
|
db,
|
|
73
70
|
row.tenantId,
|
|
74
71
|
row.ownerId,
|
|
@@ -76,10 +73,23 @@ async function releaseRowFileRefs(
|
|
|
76
73
|
row.insertedAt,
|
|
77
74
|
isCreateMode,
|
|
78
75
|
);
|
|
79
|
-
|
|
76
|
+
const systemUser = createSystemUser(row.tenantId);
|
|
77
|
+
const tenantDb = createTenantDb(db, row.tenantId, "system");
|
|
78
|
+
for (const ref of ownedRefs) {
|
|
79
|
+
const forgetResult = await fileRefExecutor.forget({ id: ref.id }, systemUser, tenantDb);
|
|
80
|
+
if (!forgetResult.isSuccess) {
|
|
81
|
+
log?.warn?.(
|
|
82
|
+
`[form-draft:cleanup] fileRef.forget failed for id=${ref.id} tenant=${row.tenantId}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
80
86
|
try {
|
|
81
87
|
const provider = await fileProviderResolver(row.tenantId);
|
|
82
|
-
await releaseDraftFileRefs(
|
|
88
|
+
await releaseDraftFileRefs(
|
|
89
|
+
ownedRefs.map((ref) => ref.storageKey),
|
|
90
|
+
(key) => provider.delete(key),
|
|
91
|
+
log,
|
|
92
|
+
);
|
|
83
93
|
} catch (err) {
|
|
84
94
|
log?.warn?.(
|
|
85
95
|
`[form-draft:cleanup] no file provider resolvable for tenant=${row.tenantId} — FileRefs NOT released (row still deleted): ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -132,9 +142,9 @@ async function deleteStaleDraftsBatch(
|
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
|
|
135
|
-
if (!ctx.db
|
|
145
|
+
if (!ctx.db) {
|
|
136
146
|
throw new InternalError({
|
|
137
|
-
message: "[form-draft:cleanup] ctx.db
|
|
147
|
+
message: "[form-draft:cleanup] ctx.db required (JobContext incomplete)",
|
|
138
148
|
});
|
|
139
149
|
}
|
|
140
150
|
const db = ctx.db as DbConnection;
|
|
@@ -163,6 +173,7 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
|
|
|
163
173
|
|
|
164
174
|
let deleted = 0;
|
|
165
175
|
while (true) {
|
|
176
|
+
if (ctx.signal?.aborted) break;
|
|
166
177
|
const batch = await selectStaleDraftsBatch(db, retentionDays, DEFAULT_BATCH_SIZE);
|
|
167
178
|
if (batch.length === 0) break;
|
|
168
179
|
|
|
@@ -66,17 +66,17 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
66
66
|
existing.insertedAt,
|
|
67
67
|
isCreateMode,
|
|
68
68
|
);
|
|
69
|
-
|
|
69
|
+
// Fail-closed erase of file_refs rows FIRST (outside the best-effort
|
|
70
|
+
// storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
|
|
71
|
+
// by design — routing forget through it would report discarded:true
|
|
72
|
+
// while leaving the row behind (#1922 / review #2009).
|
|
73
|
+
for (const ref of ownedRefs) {
|
|
74
|
+
const forgetResult = await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
|
|
75
|
+
if (!forgetResult.isSuccess) return forgetResult;
|
|
76
|
+
}
|
|
70
77
|
await releaseDraftFileRefs(
|
|
71
78
|
ownedRefs.map((ref) => ref.storageKey),
|
|
72
79
|
async (key) => {
|
|
73
|
-
// Hard-erase via the executor first (fileRef.forgotten — row +
|
|
74
|
-
// event, rebuild-safe), then the binary. A raw provider.delete()
|
|
75
|
-
// alone left the file_refs row behind forever (kumiko-framework
|
|
76
|
-
// review #1922): no event, no projection update, the row just
|
|
77
|
-
// pointed at a storage key that no longer existed.
|
|
78
|
-
const ref = refsByStorageKey.get(key);
|
|
79
|
-
if (ref !== undefined) await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
|
|
80
80
|
await files.ref(key).delete();
|
|
81
81
|
},
|
|
82
82
|
ctx.log,
|
|
@@ -569,4 +569,64 @@ describe("watch-supervisor — multi-worker watch coordination (#1719)", () => {
|
|
|
569
569
|
await supervisorB.stop();
|
|
570
570
|
}
|
|
571
571
|
});
|
|
572
|
+
|
|
573
|
+
test("losing the lease (renew fails) tears down the local watcher and lets the peer take over", async () => {
|
|
574
|
+
const admin = adminFor(4202);
|
|
575
|
+
const accountId = await connectSharedAccount(admin);
|
|
576
|
+
const lock = createDistributedLock(stack.redis.redis, "test-watch-lease-lost:");
|
|
577
|
+
const a = makeWatchIngestCounter();
|
|
578
|
+
const b = makeWatchIngestCounter();
|
|
579
|
+
const supervisorA = createInboundMailSupervisor({
|
|
580
|
+
providerCtx: { registry: stack.registry },
|
|
581
|
+
db,
|
|
582
|
+
dispatchWrite: a.dispatchWrite,
|
|
583
|
+
lock,
|
|
584
|
+
watchLeaseTtlSeconds: 1,
|
|
585
|
+
pollIntervalMs: 60_000,
|
|
586
|
+
});
|
|
587
|
+
const supervisorB = createInboundMailSupervisor({
|
|
588
|
+
providerCtx: { registry: stack.registry },
|
|
589
|
+
db,
|
|
590
|
+
dispatchWrite: b.dispatchWrite,
|
|
591
|
+
lock,
|
|
592
|
+
watchLeaseTtlSeconds: 1,
|
|
593
|
+
pollIntervalMs: 60_000,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
await supervisorA.start();
|
|
598
|
+
await waitFor(() => {
|
|
599
|
+
expect(isWatching(accountId)).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// B's own start() polls too but must not claim the watch lease — A
|
|
603
|
+
// already holds it. Priming `running` here (rather than only calling
|
|
604
|
+
// pollOnce() later) mirrors the "peer takes over" test above.
|
|
605
|
+
await supervisorB.start();
|
|
606
|
+
|
|
607
|
+
// Simulate the lease expiring behind A's back (Redis TTL lapse, or an
|
|
608
|
+
// operator flushing the key) by deleting it directly. A's next renew
|
|
609
|
+
// heartbeat (`lock.renew`, every ttl/3) then finds a token mismatch,
|
|
610
|
+
// reports failure, and must tear down A's local IDLE connection.
|
|
611
|
+
await stack.redis.redis.del(`test-watch-lease-lost:${accountId}`);
|
|
612
|
+
|
|
613
|
+
await waitFor(() => {
|
|
614
|
+
expect(isWatching(accountId)).toBe(false);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await supervisorB.pollOnce();
|
|
618
|
+
await waitFor(() => {
|
|
619
|
+
expect(isWatching(accountId)).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-lost-1" }));
|
|
623
|
+
await waitFor(() => {
|
|
624
|
+
expect(b.watchIngestCount()).toBe(1);
|
|
625
|
+
});
|
|
626
|
+
expect(a.watchIngestCount()).toBe(0);
|
|
627
|
+
} finally {
|
|
628
|
+
await supervisorA.stop();
|
|
629
|
+
await supervisorB.stop();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
572
632
|
});
|
|
@@ -99,6 +99,18 @@ describe("jobRun direct writes", () => {
|
|
|
99
99
|
expect(logs).toHaveLength(1);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
test("rounds fractional job duration to an integer millisecond value", async () => {
|
|
103
|
+
await logger.onJobStart?.("example:job:timing", "bull-timing", {});
|
|
104
|
+
// Pass a fractional ms value directly — Temporal DurationLike.nanoseconds
|
|
105
|
+
// is absolute ns (5e8 = 500ms), not a sub-ms fraction.
|
|
106
|
+
await logger.onJobComplete?.("example:job:timing", "bull-timing", 150.5, []);
|
|
107
|
+
|
|
108
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-timing" });
|
|
109
|
+
expect(runs).toHaveLength(1);
|
|
110
|
+
expect(Number.isInteger(runs[0]?.duration)).toBe(true);
|
|
111
|
+
expect(runs[0]?.duration).toBe(151);
|
|
112
|
+
});
|
|
113
|
+
|
|
102
114
|
test("start + complete both act on the SAME row", async () => {
|
|
103
115
|
await logger.onJobStart?.("example:job:stream", "bull-99", {});
|
|
104
116
|
await logger.onJobComplete?.("example:job:stream", "bull-99", 10, []);
|
|
@@ -104,6 +104,28 @@ describe("jobs run-started payload under KMS", () => {
|
|
|
104
104
|
expect(after["payload"]).toBe(PII_ERASED_SENTINEL);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
// Mirrors the log-message/error cases below: a key already erased by the
|
|
108
|
+
// time a new run starts must not blow up onJobStart itself — the run row
|
|
109
|
+
// still has to land, even if the triggering user's key is gone.
|
|
110
|
+
test("erase subject key BEFORE onJobStart: payload lands as [[erased]] instead of throwing", async () => {
|
|
111
|
+
// First run creates the subject's key — eraseKey is a no-op on an
|
|
112
|
+
// unknown subject (InMemoryKmsAdapter's tombstone contract), so the key
|
|
113
|
+
// has to exist before it can be erased.
|
|
114
|
+
await logger.onJobStart?.("app:job:export", "bull-2b", {
|
|
115
|
+
triggeredById: USER_ID,
|
|
116
|
+
payload: SECRET_PAYLOAD,
|
|
117
|
+
});
|
|
118
|
+
await kms.eraseKey({ kind: "user", userId: USER_ID });
|
|
119
|
+
|
|
120
|
+
await logger.onJobStart?.("app:job:export", "bull-2c", {
|
|
121
|
+
triggeredById: USER_ID,
|
|
122
|
+
payload: SECRET_PAYLOAD,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-2c" });
|
|
126
|
+
expect(row?.["payload"]).toBe(PII_ERASED_SENTINEL);
|
|
127
|
+
});
|
|
128
|
+
|
|
107
129
|
test("system run (no triggeredById) stays plaintext — no subject to shred", async () => {
|
|
108
130
|
await logger.onJobStart?.("app:job:cron-sweep", "bull-3", {
|
|
109
131
|
payload: JSON.stringify({ scope: "all" }),
|
|
@@ -204,35 +226,104 @@ describe("jobs run-completed/-failed log messages under KMS (#2247)", () => {
|
|
|
204
226
|
expect(logs[0]?.["message"]).toBe(SECRET_LOG);
|
|
205
227
|
});
|
|
206
228
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
// after abnormal termination) — not something this PR fixes.
|
|
215
|
-
test("erase subject key BEFORE onJobComplete: log encryption throws, run status already landed as completed", async () => {
|
|
229
|
+
// #2307: a key erased between onJobStart and onJobComplete no longer
|
|
230
|
+
// blows up the callback. encryptLogMessages catches KeyErasedError per
|
|
231
|
+
// log line and falls back to the sentinel (mirroring
|
|
232
|
+
// decryptPiiValueForSubject on the read side) instead of throwing and
|
|
233
|
+
// dropping the whole batch — the status updateMany already committed by
|
|
234
|
+
// this point, so a thrown error would lose the log batch for nothing.
|
|
235
|
+
test("erase subject key BEFORE onJobComplete: log message lands as [[erased]] instead of throwing", async () => {
|
|
216
236
|
await logger.onJobStart?.("app:job:export", "bull-c5", {
|
|
217
237
|
triggeredById: USER_ID,
|
|
218
238
|
payload: SECRET_PAYLOAD,
|
|
219
239
|
});
|
|
220
240
|
await kms.eraseKey({ kind: "user", userId: USER_ID });
|
|
221
241
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
{ level: "info", message: SECRET_LOG, timestamp: Temporal.Now.instant() },
|
|
226
|
-
]);
|
|
227
|
-
} catch {
|
|
228
|
-
threw = true;
|
|
229
|
-
}
|
|
230
|
-
expect(threw).toBe(true);
|
|
242
|
+
await logger.onJobComplete?.("app:job:export", "bull-c5", 5, [
|
|
243
|
+
{ level: "info", message: SECRET_LOG, timestamp: Temporal.Now.instant() },
|
|
244
|
+
]);
|
|
231
245
|
|
|
232
246
|
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-c5" });
|
|
233
247
|
expect(row?.["status"]).toBe("completed");
|
|
234
248
|
const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: await runIdFor("bull-c5") });
|
|
235
|
-
expect(logs).toHaveLength(
|
|
249
|
+
expect(logs).toHaveLength(1);
|
|
250
|
+
expect(logs[0]?.["message"]).toBe(PII_ERASED_SENTINEL);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// #2307: onJobFailed's `error` argument was the one plaintext leak left in
|
|
255
|
+
// this table — encryptLogMessages already encrypted logs[].message under
|
|
256
|
+
// the same triggeredById, but the sibling `error` column (same underlying
|
|
257
|
+
// string in job-runner.ts, since it pushes errorMsg into both the log
|
|
258
|
+
// batch and the onJobFailed argument) stayed plaintext. Same subject, same
|
|
259
|
+
// encrypt-under-DEK treatment, same skip rules as the logs above.
|
|
260
|
+
describe("jobs run-failed error column under KMS (#2307)", () => {
|
|
261
|
+
const SECRET_ERROR = "export failed for iban DE89370400440532013000";
|
|
262
|
+
|
|
263
|
+
async function runIdFor(bullJobId: string): Promise<string> {
|
|
264
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId });
|
|
265
|
+
return String(row?.["id"]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
test("onJobFailed: stored error column is ciphertext, decrypts to original", async () => {
|
|
269
|
+
await logger.onJobStart?.("app:job:export", "bull-e1", { triggeredById: USER_ID });
|
|
270
|
+
await logger.onJobFailed?.("app:job:export", "bull-e1", SECRET_ERROR, []);
|
|
271
|
+
|
|
272
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e1") });
|
|
273
|
+
expect(isPiiCiphertext(row?.["error"])).toBe(true);
|
|
274
|
+
expect(String(row?.["error"])).toContain(`user:${USER_ID}`);
|
|
275
|
+
|
|
276
|
+
const back = await decryptPiiFieldValues({ error: row?.["error"] }, ["error"], kms, {
|
|
277
|
+
requestId: "t",
|
|
278
|
+
});
|
|
279
|
+
expect(back["error"]).toBe(SECRET_ERROR);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("erase subject key after write → error column decrypts to [[erased]]", async () => {
|
|
283
|
+
await logger.onJobStart?.("app:job:export", "bull-e2", { triggeredById: USER_ID });
|
|
284
|
+
await logger.onJobFailed?.("app:job:export", "bull-e2", SECRET_ERROR, []);
|
|
285
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e2") });
|
|
286
|
+
|
|
287
|
+
await kms.eraseKey({ kind: "user", userId: USER_ID });
|
|
288
|
+
const after = await decryptPiiFieldValues({ error: row?.["error"] }, ["error"], kms, {
|
|
289
|
+
requestId: "t",
|
|
290
|
+
});
|
|
291
|
+
expect(after["error"]).toBe(PII_ERASED_SENTINEL);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Mirrors the log-message case above: a key erased between onJobStart and
|
|
295
|
+
// onJobFailed must not blow up the failure callback — the run's own
|
|
296
|
+
// failure still has to land, even if the triggering user's key is gone.
|
|
297
|
+
test("erase subject key BEFORE onJobFailed: error column lands as [[erased]] instead of throwing", async () => {
|
|
298
|
+
// payload creates the subject's key during onJobStart — eraseKey is a
|
|
299
|
+
// no-op on an unknown subject (InMemoryKmsAdapter's tombstone contract),
|
|
300
|
+
// so the key has to exist before it can be erased.
|
|
301
|
+
await logger.onJobStart?.("app:job:export", "bull-e3", {
|
|
302
|
+
triggeredById: USER_ID,
|
|
303
|
+
payload: SECRET_PAYLOAD,
|
|
304
|
+
});
|
|
305
|
+
await kms.eraseKey({ kind: "user", userId: USER_ID });
|
|
306
|
+
|
|
307
|
+
await logger.onJobFailed?.("app:job:export", "bull-e3", SECRET_ERROR, []);
|
|
308
|
+
|
|
309
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e3") });
|
|
310
|
+
expect(row?.["status"]).toBe("failed");
|
|
311
|
+
expect(row?.["error"]).toBe(PII_ERASED_SENTINEL);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("system run (no triggeredById) → error column stays plaintext", async () => {
|
|
315
|
+
await logger.onJobStart?.("app:job:cron-sweep", "bull-e4", {});
|
|
316
|
+
await logger.onJobFailed?.("app:job:cron-sweep", "bull-e4", "plain sweep failure", []);
|
|
317
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e4") });
|
|
318
|
+
expect(row?.["error"]).toBe("plain sweep failure");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("without a KMS the error column stays plaintext (rollout mode)", async () => {
|
|
322
|
+
await logger.onJobStart?.("app:job:export", "bull-e5", { triggeredById: USER_ID });
|
|
323
|
+
resetPiiSubjectKmsForTests();
|
|
324
|
+
await logger.onJobFailed?.("app:job:export", "bull-e5", SECRET_ERROR, []);
|
|
325
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e5") });
|
|
326
|
+
expect(row?.["error"]).toBe(SECRET_ERROR);
|
|
236
327
|
});
|
|
237
328
|
});
|
|
238
329
|
|
|
@@ -242,7 +333,7 @@ describe("jobs run-completed/-failed log messages under KMS (#2247)", () => {
|
|
|
242
333
|
// jobs:query:details HTTP handler so a regression in those eleven lines
|
|
243
334
|
// (wrong AAD field name, log-array key access, the row/log spread) fails a
|
|
244
335
|
// test instead of shipping ciphertext to the job-run detail screen.
|
|
245
|
-
describe("jobs:query:details decrypts log messages end-to-end (#2247)", () => {
|
|
336
|
+
describe("jobs:query:details decrypts log messages and error end-to-end (#2247, #2307)", () => {
|
|
246
337
|
let detailStack: TestStack;
|
|
247
338
|
let detailLogger: ReturnType<typeof createJobRunLogger>;
|
|
248
339
|
|
|
@@ -293,4 +384,51 @@ describe("jobs:query:details decrypts log messages end-to-end (#2247)", () => {
|
|
|
293
384
|
expect(result.logs).toHaveLength(1);
|
|
294
385
|
expect(result.logs[0]?.message).toBe(SECRET_DETAIL_LOG);
|
|
295
386
|
});
|
|
387
|
+
|
|
388
|
+
test("jobs:query:details returns plaintext error even though the stored row is ciphertext", async () => {
|
|
389
|
+
const SECRET_DETAIL_ERROR = "export failed for iban DE89370400440532013000";
|
|
390
|
+
await detailLogger.onJobStart?.("app:job:export", "bull-detail-2", {
|
|
391
|
+
triggeredById: DETAIL_USER_ID,
|
|
392
|
+
});
|
|
393
|
+
await detailLogger.onJobFailed?.("app:job:export", "bull-detail-2", SECRET_DETAIL_ERROR, []);
|
|
394
|
+
|
|
395
|
+
const row = await fetchOne(detailStack.db, jobRunsTable, { bullJobId: "bull-detail-2" });
|
|
396
|
+
const runId = String(row?.["id"]);
|
|
397
|
+
expect(isPiiCiphertext(row?.["error"])).toBe(true);
|
|
398
|
+
|
|
399
|
+
const result = await detailStack.http.queryOk<{ error: string }>(
|
|
400
|
+
JobQueries.details,
|
|
401
|
+
{ runId },
|
|
402
|
+
TestUsers.systemAdmin,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
expect(result.error).toBe(SECRET_DETAIL_ERROR);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("jobs:query:list returns plaintext payload + error even though the stored row is ciphertext", async () => {
|
|
409
|
+
const SECRET_LIST_ERROR = "export failed for iban DE89370400440532013000";
|
|
410
|
+
// Distinct jobName + filter — this describe block's beforeEach swaps in a
|
|
411
|
+
// brand-new KMS per test, so earlier tests' rows are still in this
|
|
412
|
+
// shared table but encrypted under keys the current KMS instance no
|
|
413
|
+
// longer holds. An unfiltered list() would sweep those up too and fail
|
|
414
|
+
// to decrypt them.
|
|
415
|
+
const jobName = "app:job:export-list-pii-test";
|
|
416
|
+
await detailLogger.onJobStart?.(jobName, "bull-detail-3", {
|
|
417
|
+
triggeredById: DETAIL_USER_ID,
|
|
418
|
+
payload: SECRET_PAYLOAD,
|
|
419
|
+
});
|
|
420
|
+
await detailLogger.onJobFailed?.(jobName, "bull-detail-3", SECRET_LIST_ERROR, []);
|
|
421
|
+
|
|
422
|
+
const row = await fetchOne(detailStack.db, jobRunsTable, { bullJobId: "bull-detail-3" });
|
|
423
|
+
expect(isPiiCiphertext(row?.["payload"])).toBe(true);
|
|
424
|
+
expect(isPiiCiphertext(row?.["error"])).toBe(true);
|
|
425
|
+
|
|
426
|
+
const result = await detailStack.http.queryOk<{
|
|
427
|
+
rows: readonly { id: string; payload: string | null; error: string | null }[];
|
|
428
|
+
}>(JobQueries.list, { jobName, limit: 50 }, TestUsers.systemAdmin);
|
|
429
|
+
|
|
430
|
+
const listed = result.rows.find((r) => r.id === String(row?.["id"]));
|
|
431
|
+
expect(listed?.payload).toBe(SECRET_PAYLOAD);
|
|
432
|
+
expect(listed?.error).toBe(SECRET_LIST_ERROR);
|
|
433
|
+
});
|
|
296
434
|
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -56,7 +56,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
56
56
|
// Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
|
|
57
57
|
r.job(
|
|
58
58
|
"projectionRebuild",
|
|
59
|
-
{ trigger: { manual: true }, schema: projectionRebuildPayloadSchema },
|
|
59
|
+
{ trigger: { manual: true }, concurrency: "skip", schema: projectionRebuildPayloadSchema },
|
|
60
60
|
projectionRebuildJob,
|
|
61
61
|
);
|
|
62
62
|
|
|
@@ -66,7 +66,12 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
66
66
|
// too, not just cron).
|
|
67
67
|
r.job(
|
|
68
68
|
"reindexEntity",
|
|
69
|
-
{
|
|
69
|
+
{
|
|
70
|
+
trigger: { manual: true },
|
|
71
|
+
perTenant: true,
|
|
72
|
+
concurrency: "skip",
|
|
73
|
+
schema: reindexEntityPayloadSchema,
|
|
74
|
+
},
|
|
70
75
|
reindexEntityJob,
|
|
71
76
|
);
|
|
72
77
|
|
|
@@ -2,9 +2,14 @@ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { decryptStoredPii } from "../../shared";
|
|
5
|
+
import { decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
6
6
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
7
7
|
|
|
8
|
+
// Matches job-run-logger.ts's KMS_POOL_CONCURRENCY / the tenant handlers'
|
|
9
|
+
// KMS_POOL_CONCURRENCY — bounds concurrent decrypt calls against the KMS
|
|
10
|
+
// adapter's connection pool.
|
|
11
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
12
|
+
|
|
8
13
|
export const detailQuery = defineQueryHandler({
|
|
9
14
|
name: "details",
|
|
10
15
|
// Post-ES: runId is the uuid aggregate-id of the jobRun event-stream.
|
|
@@ -30,6 +35,12 @@ export const detailQuery = defineQueryHandler({
|
|
|
30
35
|
row["payload"] = await decryptStoredPii(row["payload"], "payload", "job-run-detail");
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
// error is stored encrypted under the triggering user's DEK (#2307),
|
|
39
|
+
// same mechanism as row.payload above.
|
|
40
|
+
if (typeof row["error"] === "string") {
|
|
41
|
+
row["error"] = await decryptStoredPii(row["error"], "error", "job-run-detail");
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
const logs = await selectMany(
|
|
34
45
|
db,
|
|
35
46
|
jobRunLogsTable,
|
|
@@ -40,16 +51,16 @@ export const detailQuery = defineQueryHandler({
|
|
|
40
51
|
);
|
|
41
52
|
|
|
42
53
|
// message is stored encrypted under the triggering user's DEK (#2247),
|
|
43
|
-
// same mechanism as row.payload above.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
);
|
|
54
|
+
// same mechanism as row.payload above. Bounded concurrency (#2307) —
|
|
55
|
+
// unbounded Promise.all would fire one decrypt per log line at once
|
|
56
|
+
// against the KMS adapter's pool.
|
|
57
|
+
const decryptedLogs = await mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => {
|
|
58
|
+
if (typeof log["message"] !== "string") return log;
|
|
59
|
+
return {
|
|
60
|
+
...log,
|
|
61
|
+
message: await decryptStoredPii(log["message"], "message", "job-run-detail-log"),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
53
64
|
|
|
54
65
|
return { ...row, logs: decryptedLogs };
|
|
55
66
|
},
|
|
@@ -2,12 +2,23 @@ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { decryptStoredPii } from "../../shared";
|
|
5
|
+
import { decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
6
6
|
import { jobRunsTable } from "../job-run-table";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
9
|
+
|
|
10
|
+
async function decryptRunRow<T extends Record<string, unknown>>(row: T): Promise<T> {
|
|
11
|
+
let result = row;
|
|
12
|
+
if (typeof result["payload"] === "string") {
|
|
13
|
+
result = {
|
|
14
|
+
...result,
|
|
15
|
+
payload: await decryptStoredPii(result["payload"], "payload", "job-runs"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (typeof result["error"] === "string") {
|
|
19
|
+
result = { ...result, error: await decryptStoredPii(result["error"], "error", "job-runs") };
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
export const listQuery = defineQueryHandler({
|
|
@@ -32,7 +43,7 @@ export const listQuery = defineQueryHandler({
|
|
|
32
43
|
orderBy: { col: "id", direction: "desc" },
|
|
33
44
|
limit: query.payload.limit ?? 50,
|
|
34
45
|
});
|
|
35
|
-
// payload
|
|
36
|
-
return { rows: await
|
|
46
|
+
// payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
|
|
47
|
+
return { rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow) };
|
|
37
48
|
},
|
|
38
49
|
});
|