@cosmicdrift/kumiko-bundled-features 0.364.0 → 0.365.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/dist/audit/feature.js +1 -1
- package/dist/audit/run-escape-hatch-retention.d.ts +1 -0
- package/dist/audit/run-escape-hatch-retention.js +39 -12
- package/dist/auth-email-password/bootstrap.d.ts +1 -3
- package/dist/auth-email-password/feature.js +2 -0
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -3
- package/dist/auth-email-password/handlers/invite-create.write.d.ts +2 -3
- package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +1 -1
- package/dist/auth-email-password/magic-link-mail.js +6 -3
- package/dist/auth-email-password/mail-i18n.d.ts +3 -0
- package/dist/auth-email-password/mail-i18n.js +10 -0
- package/dist/billing-foundation/consumer-protection/termination-record.js +8 -1
- package/dist/billing-foundation/i18n.js +6 -0
- package/dist/billing-foundation/subscription-tier-sync.js +8 -2
- package/dist/cap-counter/book-cap-usage.js +8 -0
- package/dist/cap-counter/cap-reservation.js +7 -3
- package/dist/cap-counter/handlers/increment-rolling.write.d.ts +1 -0
- package/dist/cap-counter/handlers/increment-rolling.write.js +2 -0
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -1
- package/dist/cap-overview/handlers/tenant-options.query.js +3 -1
- package/dist/channel-discord/discord-channel.js +3 -2
- package/dist/channel-email/email-channel.js +24 -19
- package/dist/channel-email/smtp-transport.js +6 -1
- package/dist/channel-slack/slack-channel.js +3 -2
- package/dist/channel-teams/teams-channel.js +3 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.js +2 -2
- package/dist/custom-fields/db/queries/retention.d.ts +1 -1
- package/dist/custom-fields/db/queries/retention.js +2 -1
- package/dist/data-retention/run-retention-cleanup.js +4 -5
- package/dist/delivery/attachment-ref.js +16 -1
- package/dist/delivery/delivery-service.js +71 -82
- package/dist/feature-toggles/toggle-runtime.d.ts +5 -3
- package/dist/feature-toggles/toggle-runtime.js +25 -12
- package/dist/files-tenant-data/index.d.ts +1 -0
- package/dist/files-tenant-data/index.js +2 -1
- package/dist/form-draft/db/queries/owned-file-refs.d.ts +1 -1
- package/dist/form-draft/db/queries/owned-file-refs.js +9 -2
- package/dist/form-draft/handlers/discard.write.js +1 -1
- package/dist/jobs/job-run-logger.js +5 -2
- package/dist/metrics/filters.d.ts +16 -1
- package/dist/metrics/filters.js +27 -11
- package/dist/personal-access-tokens/revoke-for-user.js +3 -7
- package/dist/secrets/handlers/delete.write.js +3 -1
- package/dist/secrets/write-gate.d.ts +3 -1
- package/dist/secrets/write-gate.js +6 -3
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/resource-image-formats.d.ts +23 -0
- package/dist/shared/resource-image-formats.js +21 -0
- package/dist/shared/sharp-input-limits.js +7 -8
- package/dist/step-dispatcher/webhook-runner.js +2 -2
- package/dist/template-resolver/compile-document-body.d.ts +3 -0
- package/dist/template-resolver/compile-document-body.js +63 -12
- package/dist/template-resolver/compile-steps-block.d.ts +10 -0
- package/dist/template-resolver/compile-steps-block.js +45 -0
- package/dist/template-resolver/constants.d.ts +4 -1
- package/dist/template-resolver/constants.js +10 -1
- package/dist/template-resolver/document-sanitizer.js +3 -0
- package/dist/template-resolver/document-schema.d.ts +2 -1
- package/dist/template-resolver/document-schema.js +6 -1
- package/dist/template-resolver/document-steps.d.ts +17 -0
- package/dist/template-resolver/document-steps.js +93 -0
- package/dist/template-resolver/feature.js +9 -0
- package/dist/template-resolver/handlers/bundle-import.d.ts +7 -1
- package/dist/template-resolver/handlers/bundle-import.js +63 -38
- package/dist/template-resolver/handlers/collection-set.write.js +4 -3
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +1 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +83 -55
- package/dist/template-resolver/handlers/diff-to-system.query.js +1 -0
- package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -1
- package/dist/template-resolver/handlers/import-bundle-system.write.js +16 -5
- package/dist/template-resolver/handlers/import-bundle.write.d.ts +1 -1
- package/dist/template-resolver/handlers/import-bundle.write.js +11 -1
- package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
- package/dist/template-resolver/handlers/overview.query.js +9 -1
- package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -2
- package/dist/template-resolver/handlers/resource-upload.write.js +35 -5
- package/dist/template-resolver/handlers/validate.query.js +4 -1
- package/dist/template-resolver/index.d.ts +3 -3
- package/dist/template-resolver/index.js +3 -3
- package/dist/template-resolver/layout-appearance.d.ts +1 -1
- package/dist/template-resolver/layout-appearance.js +19 -9
- package/dist/template-resolver/mail-constants.d.ts +2 -0
- package/dist/template-resolver/mail-constants.js +3 -0
- package/dist/template-resolver/resource-image.d.ts +2 -9
- package/dist/template-resolver/resource-image.js +2 -11
- package/dist/template-resolver/seeding.js +2 -0
- package/dist/template-resolver/store-template-resource.d.ts +53 -9
- package/dist/template-resolver/store-template-resource.js +250 -97
- package/dist/template-resolver/template-bundle.js +4 -0
- package/dist/template-resolver/template-draft.d.ts +3 -0
- package/dist/template-resolver/template-draft.js +1 -1
- package/dist/template-resolver/template-meta.d.ts +5 -1
- package/dist/template-resolver/template-meta.js +7 -0
- package/dist/template-resolver/template-preview.js +1 -2
- package/dist/template-resolver/template-resources.d.ts +0 -5
- package/dist/template-resolver/template-resources.js +2 -34
- package/dist/template-resolver/template-validation.js +12 -9
- package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
- package/dist/template-resolver/web/document-editor-extensions.js +26 -1
- package/dist/template-resolver/web/document-editor.js +5 -1
- package/dist/template-resolver/web/document-tiptap-editor.js +30 -7
- package/dist/template-resolver/web/document-toolbar-blocks.d.ts +5 -5
- package/dist/template-resolver/web/document-toolbar-blocks.js +2 -0
- package/dist/template-resolver/web/i18n.js +5 -0
- package/dist/template-resolver/web/index.d.ts +1 -0
- package/dist/template-resolver/web/index.js +1 -0
- package/dist/template-resolver/web/placeholder-line-editor.d.ts +4 -2
- package/dist/template-resolver/web/placeholder-line-editor.js +8 -6
- package/dist/tenant/handlers/update-member-roles.write.js +3 -6
- package/dist/tenant/seeding.js +23 -24
- package/dist/tenant-handover/move-entity-graph.js +14 -4
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +1 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +8 -1
- package/dist/tenant-lifecycle/stages.js +3 -2
- package/dist/tier-engine/constants.d.ts +2 -0
- package/dist/tier-engine/constants.js +4 -0
- package/dist/tier-engine/entity.d.ts +15 -14
- package/dist/tier-engine/entity.js +4 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +5 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -4
- package/dist/tier-engine/index.d.ts +1 -1
- package/dist/tier-engine/index.js +1 -1
- package/dist/tier-engine/tier-resolver.d.ts +13 -13
- package/dist/user/constants.d.ts +1 -0
- package/dist/user/invited-global-roles.d.ts +2 -4
- package/dist/user-data-rights/email-templates.js +0 -1
- package/dist/user-data-rights/i18n.js +5 -0
- package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +2 -0
- package/dist/user-data-rights/lib/update-user-lifecycle.js +6 -2
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/waitlist/feature.js +1 -1
- package/dist/waitlist/handlers/invite.write.js +21 -3
- package/dist/waitlist/handlers/reject.write.js +3 -16
- package/dist/waitlist/handlers/submit.write.js +29 -7
- package/dist/waitlist/i18n.js +9 -0
- package/dist/waitlist/lib.d.ts +2 -1
- package/dist/waitlist/lib.js +15 -0
- package/dist/workflow-runner/feature.js +1 -1
- package/package.json +10 -10
- package/src/audit/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/billing-foundation/changes.json +12 -0
- package/src/cap-counter/changes.json +13 -0
- package/src/cap-overview/changes.json +6 -0
- package/src/channel-email/changes.json +18 -0
- package/src/delivery/changes.json +12 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/feature-toggles/changes.json +7 -0
- package/src/jobs/changes.json +12 -0
- package/src/metrics/changes.json +6 -0
- package/src/personal-access-tokens/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/template-resolver/changes.json +81 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/tier-engine/changes.json +12 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/waitlist/changes.json +18 -0
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
41
41
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
42
42
|
import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
|
|
43
|
-
import {
|
|
43
|
+
import { deleteFileRefBytes } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
44
44
|
import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
45
|
import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
|
|
46
46
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
@@ -250,9 +250,9 @@ async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId,
|
|
|
250
250
|
}
|
|
251
251
|
// Throws (unsafe key, missing list permission) must fail this row closed,
|
|
252
252
|
// not abort the whole tenant run.
|
|
253
|
-
async function deleteFileFailingClosed(key,
|
|
253
|
+
async function deleteFileFailingClosed(key, files, logContext) {
|
|
254
254
|
try {
|
|
255
|
-
return await
|
|
255
|
+
return await deleteFileRefBytes(files, { storageKey: key }, "data-retention:hardDelete");
|
|
256
256
|
}
|
|
257
257
|
catch (err) {
|
|
258
258
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
@@ -304,13 +304,12 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
304
304
|
return false;
|
|
305
305
|
}
|
|
306
306
|
const files = args.files;
|
|
307
|
-
const store = { list: files.list, delete: (key) => files.ref(key).delete() };
|
|
308
307
|
let anyFailed = false;
|
|
309
308
|
for (const fileRef of toDelete) {
|
|
310
309
|
const key = fileRef["storageKey"];
|
|
311
310
|
if (typeof key !== "string" || key.length === 0)
|
|
312
311
|
continue;
|
|
313
|
-
const failedKeys = await deleteFileFailingClosed(key,
|
|
312
|
+
const failedKeys = await deleteFileFailingClosed(key, files, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
|
|
314
313
|
if (failedKeys.length > 0) {
|
|
315
314
|
anyFailed = true;
|
|
316
315
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
@@ -33,13 +33,28 @@ export const fileAttachmentRefSchema = z
|
|
|
33
33
|
fileName: attachmentFileNameSchema,
|
|
34
34
|
})
|
|
35
35
|
.strict();
|
|
36
|
+
// BigInt values and cycles make JSON.stringify throw; that must surface as a validation issue,
|
|
37
|
+
// not a raw TypeError that a retrying job hits again on the same data.
|
|
38
|
+
function isJsonSerializable(value) {
|
|
39
|
+
try {
|
|
40
|
+
JSON.stringify(value);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function jsonByteLength(value) {
|
|
48
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
49
|
+
}
|
|
36
50
|
export const renderedPdfAttachmentRefSchema = z
|
|
37
51
|
.object({
|
|
38
52
|
kind: z.literal("rendered-pdf"),
|
|
39
53
|
templateSlug: templateSlugSchema,
|
|
40
54
|
variables: z
|
|
41
55
|
.record(z.string(), z.unknown())
|
|
42
|
-
.refine(
|
|
56
|
+
.refine(isJsonSerializable, { message: "variables must be JSON-serializable" })
|
|
57
|
+
.refine((value) => !isJsonSerializable(value) || jsonByteLength(value) <= MAX_RENDERED_PDF_VARIABLES_BYTES, {
|
|
43
58
|
message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
|
|
44
59
|
}),
|
|
45
60
|
fileName: attachmentFileNameSchema.refine((name) => name.toLowerCase().endsWith(".pdf"), {
|
|
@@ -79,11 +79,20 @@ export function createDeliveryService(options) {
|
|
|
79
79
|
}
|
|
80
80
|
// A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
|
|
81
81
|
// Partial success keeps it: a retry would re-send to those who got it.
|
|
82
|
-
|
|
82
|
+
// A failing release is logged and swallowed: it must neither replace the dispatch error the
|
|
83
|
+
// caller retries on nor fail a notify() that already dispatched.
|
|
84
|
+
async function releaseIdempotencyIfNothingDelivered(tenantId, key, run, notificationType) {
|
|
83
85
|
// skip: no key claimed, or a recipient already got the notification
|
|
84
86
|
if (!key || run.deliveries.some(isDeliveredOrQueued))
|
|
85
87
|
return;
|
|
86
|
-
|
|
88
|
+
try {
|
|
89
|
+
await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
|
|
90
|
+
}
|
|
91
|
+
catch (releaseErr) {
|
|
92
|
+
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
93
|
+
notificationType,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
87
96
|
}
|
|
88
97
|
// Only a single-channel route lets the queued job free the key on terminal failure
|
|
89
98
|
// without risking a duplicate send on another channel.
|
|
@@ -290,35 +299,50 @@ export function createDeliveryService(options) {
|
|
|
290
299
|
return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
|
|
291
300
|
}
|
|
292
301
|
const recipientChannelKey = (userId, channelName) => `${userId}|${channelName}`;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
302
|
+
// Kill switch, then the user's preference, then the rate limit — the order deliverToUser reports
|
|
303
|
+
// skips in. The rate limit consumes a token, so each recipient/channel pair is evaluated once.
|
|
304
|
+
async function evaluateChannelGate(run, userId, tenantId, notificationType, channelName, priority) {
|
|
305
|
+
if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) {
|
|
306
|
+
return "channel_disabled";
|
|
307
|
+
}
|
|
308
|
+
// Critical priority skips the preference check
|
|
309
|
+
if (priority !== "critical" &&
|
|
310
|
+
!(await isChannelEnabled(run, userId, tenantId, notificationType, channelName))) {
|
|
311
|
+
return "preference_disabled";
|
|
312
|
+
}
|
|
313
|
+
if (rateLimit && !(await checkRateLimit(rateLimit, tenantId, channelName))) {
|
|
314
|
+
return "rate_limited";
|
|
315
|
+
}
|
|
316
|
+
return "open";
|
|
297
317
|
}
|
|
298
318
|
// Resolves every user's channel addresses up front so the opt-out lookup can
|
|
299
|
-
// cover them all at once. A resolve that throws stays out of the map:
|
|
300
|
-
// delivery loop resolves it again and reports channel_error as before.
|
|
301
|
-
// Channels
|
|
302
|
-
// here: resolve may have side effects and deliverToUser skips those channels.
|
|
303
|
-
|
|
319
|
+
// cover them all at once. A resolve that throws stays out of the address map:
|
|
320
|
+
// the delivery loop resolves it again and reports channel_error as before.
|
|
321
|
+
// Channels that are gated (kill switch, preferences, rate limit) are never resolved
|
|
322
|
+
// here: resolve may have side effects and deliverToUser skips those channels. The
|
|
323
|
+
// gate verdict is kept so deliverToUser does not query it a second time.
|
|
324
|
+
async function prefetchRecipients(run, userIds, tenantId, notificationType, priority) {
|
|
304
325
|
const addresses = new Map();
|
|
326
|
+
const gates = new Map();
|
|
305
327
|
const channelCtx = buildChannelContext(run.db, registry, sseBroker, tenantId, secrets, tenantConfigFor?.(tenantId), run.filesForTenant);
|
|
306
328
|
for (const userId of userIds) {
|
|
307
329
|
for (const channel of channels) {
|
|
308
330
|
if (!channel.resolve)
|
|
309
331
|
continue;
|
|
332
|
+
const key = recipientChannelKey(userId, channel.name);
|
|
310
333
|
try {
|
|
311
|
-
|
|
334
|
+
const gate = await evaluateChannelGate(run, userId, tenantId, notificationType, channel.name, priority);
|
|
335
|
+
gates.set(key, gate);
|
|
336
|
+
if (gate !== "open")
|
|
312
337
|
continue;
|
|
313
|
-
|
|
314
|
-
addresses.set(recipientChannelKey(userId, channel.name), await channel.resolve(userId, channelCtx));
|
|
338
|
+
addresses.set(key, await channel.resolve(userId, channelCtx));
|
|
315
339
|
}
|
|
316
340
|
catch {
|
|
317
341
|
// skip: re-resolved (and logged) by deliverToUser
|
|
318
342
|
}
|
|
319
343
|
}
|
|
320
344
|
}
|
|
321
|
-
return addresses;
|
|
345
|
+
return { addresses, gates };
|
|
322
346
|
}
|
|
323
347
|
// Check if user has disabled this notification+channel combo.
|
|
324
348
|
// Specificity order: exact > any wildcard. When only wildcards match and they
|
|
@@ -341,68 +365,33 @@ export function createDeliveryService(options) {
|
|
|
341
365
|
// and conservative — DB ordering no longer decides the outcome).
|
|
342
366
|
return !prefs.some((p) => p.enabled === false);
|
|
343
367
|
}
|
|
344
|
-
async function deliverToUser(userId, notificationType, data, tenantId, priority, run,
|
|
368
|
+
async function deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetched, suppressed) {
|
|
345
369
|
const channelCtx = buildChannelContext(run.db, registry, sseBroker, tenantId, secrets, tenantConfigFor?.(tenantId), run.filesForTenant);
|
|
346
370
|
for (const channel of channels) {
|
|
347
371
|
// Route-only channel (no per-user address): not a user-notification target.
|
|
348
372
|
if (!channel.resolve)
|
|
349
373
|
continue;
|
|
350
374
|
const message = buildMessage(notificationType, data, channel.name, run.locale);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// Check preferences (critical priority skips preference check)
|
|
369
|
-
if (priority !== "critical") {
|
|
370
|
-
const enabled = await isChannelEnabled(run, userId, tenantId, notificationType, channel.name);
|
|
371
|
-
if (!enabled) {
|
|
372
|
-
await logDelivery(run, {
|
|
373
|
-
tenantId,
|
|
374
|
-
notificationType,
|
|
375
|
-
channel: channel.name,
|
|
376
|
-
recipientId: userId,
|
|
377
|
-
recipientAddress: null,
|
|
378
|
-
status: "skipped",
|
|
379
|
-
error: "preference_disabled",
|
|
380
|
-
priority,
|
|
381
|
-
});
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
// Rate limiting
|
|
386
|
-
if (rateLimit) {
|
|
387
|
-
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
388
|
-
if (!allowed) {
|
|
389
|
-
await logDelivery(run, {
|
|
390
|
-
tenantId,
|
|
391
|
-
notificationType,
|
|
392
|
-
channel: channel.name,
|
|
393
|
-
recipientId: userId,
|
|
394
|
-
recipientAddress: null,
|
|
395
|
-
status: "skipped",
|
|
396
|
-
error: "rate_limited",
|
|
397
|
-
priority,
|
|
398
|
-
});
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
375
|
+
const prefetchKey = recipientChannelKey(userId, channel.name);
|
|
376
|
+
const gate = prefetched.gates.get(prefetchKey) ??
|
|
377
|
+
(await evaluateChannelGate(run, userId, tenantId, notificationType, channel.name, priority));
|
|
378
|
+
if (gate !== "open") {
|
|
379
|
+
await logDelivery(run, {
|
|
380
|
+
tenantId,
|
|
381
|
+
notificationType,
|
|
382
|
+
channel: channel.name,
|
|
383
|
+
recipientId: userId,
|
|
384
|
+
recipientAddress: null,
|
|
385
|
+
status: "skipped",
|
|
386
|
+
error: gate,
|
|
387
|
+
priority,
|
|
388
|
+
});
|
|
389
|
+
continue;
|
|
401
390
|
}
|
|
402
391
|
try {
|
|
403
|
-
const
|
|
404
|
-
const address =
|
|
405
|
-
?
|
|
392
|
+
const wasPrefetched = prefetched.addresses.has(prefetchKey);
|
|
393
|
+
const address = wasPrefetched
|
|
394
|
+
? prefetched.addresses.get(prefetchKey)
|
|
406
395
|
: await channel.resolve(userId, channelCtx);
|
|
407
396
|
if (!address) {
|
|
408
397
|
await logDelivery(run, {
|
|
@@ -417,7 +406,12 @@ export function createDeliveryService(options) {
|
|
|
417
406
|
});
|
|
418
407
|
continue;
|
|
419
408
|
}
|
|
420
|
-
|
|
409
|
+
// A fallback resolve yields an address the batched opt-out lookup never covered.
|
|
410
|
+
const suppressedForAddress = wasPrefetched
|
|
411
|
+
? suppressed
|
|
412
|
+
: await loadSuppressedAddresses(run, tenantId, notificationType, priority, [address]);
|
|
413
|
+
if (priority !== "critical" &&
|
|
414
|
+
isAddressSuppressed(suppressedForAddress, address, channel.name)) {
|
|
421
415
|
await logDelivery(run, {
|
|
422
416
|
tenantId,
|
|
423
417
|
notificationType,
|
|
@@ -551,12 +545,12 @@ export function createDeliveryService(options) {
|
|
|
551
545
|
else {
|
|
552
546
|
userIds = to;
|
|
553
547
|
}
|
|
554
|
-
const
|
|
555
|
-
? new Map()
|
|
556
|
-
: await
|
|
557
|
-
const suppressed = await loadSuppressedAddresses(run, tenantId, notificationType, priority, [...
|
|
548
|
+
const prefetched = priority === "critical"
|
|
549
|
+
? { addresses: new Map(), gates: new Map() }
|
|
550
|
+
: await prefetchRecipients(run, userIds, tenantId, notificationType, priority);
|
|
551
|
+
const suppressed = await loadSuppressedAddresses(run, tenantId, notificationType, priority, [...prefetched.addresses.values()].filter((address) => !!address));
|
|
558
552
|
for (const userId of userIds) {
|
|
559
|
-
await deliverToUser(userId, notificationType, data, tenantId, priority, run,
|
|
553
|
+
await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetched, suppressed);
|
|
560
554
|
}
|
|
561
555
|
}
|
|
562
556
|
}
|
|
@@ -599,15 +593,10 @@ export function createDeliveryService(options) {
|
|
|
599
593
|
await dispatchNotification(notificationType, options, tenantId, priority, run);
|
|
600
594
|
}
|
|
601
595
|
catch (err) {
|
|
602
|
-
|
|
603
|
-
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch((releaseErr) => {
|
|
604
|
-
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
605
|
-
notificationType,
|
|
606
|
-
});
|
|
607
|
-
});
|
|
596
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
|
|
608
597
|
throw err;
|
|
609
598
|
}
|
|
610
|
-
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
|
|
599
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
|
|
611
600
|
return dispatched(run);
|
|
612
601
|
},
|
|
613
602
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
|
|
3
|
+
import { type CacheSyncBus, type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
|
|
4
4
|
export type ToggleSyncSignal = {
|
|
5
5
|
publish(featureName: string, enabled: boolean): void;
|
|
6
6
|
onMessage(listener: (featureName: string, enabled: boolean) => void): void;
|
|
@@ -12,14 +12,16 @@ export declare class GlobalFeatureToggleRuntime {
|
|
|
12
12
|
private readonly syncSignal?;
|
|
13
13
|
private readonly ownedBus?;
|
|
14
14
|
private snapshot;
|
|
15
|
-
private
|
|
15
|
+
private readonly pendingFlipBuffers;
|
|
16
|
+
private loadInFlight;
|
|
16
17
|
constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined, ownedBus?: ClosableCacheSyncBus | undefined);
|
|
17
18
|
close(): Promise<void>;
|
|
18
19
|
initialize(): Promise<void>;
|
|
20
|
+
private loadSnapshot;
|
|
19
21
|
refresh(): Promise<void>;
|
|
20
22
|
apply(featureName: string, enabled: boolean): void;
|
|
21
23
|
broadcastToggle(featureName: string, enabled: boolean): void;
|
|
22
24
|
readOverride(featureName: string): boolean | undefined;
|
|
23
25
|
effectiveFeatures: () => ReadonlySet<string>;
|
|
24
26
|
}
|
|
25
|
-
export declare function createFeatureToggleRuntime(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal): Promise<GlobalFeatureToggleRuntime>;
|
|
27
|
+
export declare function createFeatureToggleRuntime(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal, cacheSyncBus?: CacheSyncBus): Promise<GlobalFeatureToggleRuntime>;
|
|
@@ -22,9 +22,11 @@ export class GlobalFeatureToggleRuntime {
|
|
|
22
22
|
syncSignal;
|
|
23
23
|
ownedBus;
|
|
24
24
|
snapshot = new Map();
|
|
25
|
-
//
|
|
26
|
-
// started before
|
|
27
|
-
|
|
25
|
+
// One buffer per requested load, open from the call until its SELECT finished: the SELECT
|
|
26
|
+
// may have started before a flip committed, so its rows can be older than the flips collected.
|
|
27
|
+
pendingFlipBuffers = new Set();
|
|
28
|
+
// Loads run one at a time so the older rows of one cannot overwrite the result of another.
|
|
29
|
+
loadInFlight = Promise.resolve();
|
|
28
30
|
constructor(db, registry, syncSignal, ownedBus) {
|
|
29
31
|
this.db = db;
|
|
30
32
|
this.registry = registry;
|
|
@@ -47,9 +49,14 @@ export class GlobalFeatureToggleRuntime {
|
|
|
47
49
|
async close() {
|
|
48
50
|
await this.ownedBus?.close();
|
|
49
51
|
}
|
|
50
|
-
|
|
52
|
+
initialize() {
|
|
51
53
|
const pendingFlips = new Map();
|
|
52
|
-
this.
|
|
54
|
+
this.pendingFlipBuffers.add(pendingFlips);
|
|
55
|
+
const load = this.loadInFlight.then(() => this.loadSnapshot(pendingFlips));
|
|
56
|
+
this.loadInFlight = load.catch(() => undefined);
|
|
57
|
+
return load;
|
|
58
|
+
}
|
|
59
|
+
async loadSnapshot(pendingFlips) {
|
|
53
60
|
try {
|
|
54
61
|
const rows = await selectMany(this.db, globalFeatureStateTable);
|
|
55
62
|
this.snapshot = new Map([
|
|
@@ -58,7 +65,7 @@ export class GlobalFeatureToggleRuntime {
|
|
|
58
65
|
]);
|
|
59
66
|
}
|
|
60
67
|
finally {
|
|
61
|
-
this.
|
|
68
|
+
this.pendingFlipBuffers.delete(pendingFlips);
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
71
|
// Re-read the full snapshot. Called from the set-handler after a
|
|
@@ -74,7 +81,8 @@ export class GlobalFeatureToggleRuntime {
|
|
|
74
81
|
// lock. Kept alongside refresh() so both options are explicit.
|
|
75
82
|
apply(featureName, enabled) {
|
|
76
83
|
this.snapshot.set(featureName, enabled);
|
|
77
|
-
this.
|
|
84
|
+
for (const pendingFlips of this.pendingFlipBuffers)
|
|
85
|
+
pendingFlips.set(featureName, enabled);
|
|
78
86
|
}
|
|
79
87
|
// Called by the toggle-cache-sync MSP handler (feature.ts) — the one
|
|
80
88
|
// process whose shared cursor won a given toggle-set event. With a
|
|
@@ -116,11 +124,16 @@ export class GlobalFeatureToggleRuntime {
|
|
|
116
124
|
}
|
|
117
125
|
// Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
|
|
118
126
|
// set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
|
|
119
|
-
// syncSignal the
|
|
120
|
-
// REDIS_URL is set, process-local otherwise. close() releases
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
// syncSignal the signal runs on the given cacheSyncBus or, failing that, on a bus the runtime
|
|
128
|
+
// creates itself (Redis-backed when REDIS_URL is set, process-local otherwise). close() releases
|
|
129
|
+
// only a bus the runtime created.
|
|
130
|
+
export async function createFeatureToggleRuntime(db, registry, syncSignal,
|
|
131
|
+
// The server's own bus (the one handed to the dispatcher): reusing it avoids a second set of
|
|
132
|
+
// Redis connections that nothing closes at shutdown.
|
|
133
|
+
cacheSyncBus) {
|
|
134
|
+
const ownedBus = syncSignal || cacheSyncBus ? undefined : createDefaultCacheSyncBus();
|
|
135
|
+
const sharedBus = cacheSyncBus ?? ownedBus;
|
|
136
|
+
const signal = syncSignal ?? (sharedBus ? createCacheSyncToggleSignal(sharedBus) : undefined);
|
|
124
137
|
const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
|
|
125
138
|
try {
|
|
126
139
|
await runtime.initialize();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
export declare const FILES_TENANT_DATA_FEATURE: "files-tenant-data";
|
|
2
3
|
export declare function createFilesTenantDataFeature(): FeatureDefinition;
|
|
3
4
|
export { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
|
|
4
5
|
export { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
|
|
@@ -14,8 +14,9 @@ import { defineFeature, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, } from "@cosmicdr
|
|
|
14
14
|
import { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
|
|
15
15
|
import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
|
|
16
16
|
const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON = "iterates every tenant and checks fileRef owners per tenant";
|
|
17
|
+
export const FILES_TENANT_DATA_FEATURE = "files-tenant-data";
|
|
17
18
|
export function createFilesTenantDataFeature() {
|
|
18
|
-
return defineFeature(
|
|
19
|
+
return defineFeature(FILES_TENANT_DATA_FEATURE, (r) => {
|
|
19
20
|
r.describe("GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.");
|
|
20
21
|
r.uiHints({
|
|
21
22
|
displayLabel: "Files · Tenant-Destroy & Derivative GC",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
export declare function filterOwnedStorageKeys(db: DbRunner, tenantId: TenantId, ownerId: string, candidateKeys: readonly string[], draftInsertedAt: Temporal.Instant, isCreateMode: boolean): Promise<readonly string[]>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { instantToTimestamptzParam, unsafeReadRetrying, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
// Narrows a draft blob's raw FileRef-shaped storageKeys (collectDraftFileRefKeys
|
|
3
4
|
// output — extracted from free-form, client-supplied JSON, issue #1889) down to
|
|
4
5
|
// the ones with a real, non-deleted file_refs row owned by this exact
|
|
@@ -32,6 +33,12 @@ export async function filterOwnedFileRefs(db, tenantId, ownerId, candidateKeys,
|
|
|
32
33
|
const rows = await unsafeReadRetrying(db, `SELECT "id", "storage_key" FROM "file_refs"
|
|
33
34
|
WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
|
|
34
35
|
AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
|
|
35
|
-
AND ($5::boolean OR "inserted_at" > $4::timestamptz)`, [
|
|
36
|
+
AND ($5::boolean OR "inserted_at" > $4::timestamptz)`, [
|
|
37
|
+
tenantId,
|
|
38
|
+
ownerId,
|
|
39
|
+
toPostgresArrayLiteral(candidateKeys),
|
|
40
|
+
instantToTimestamptzParam(draftInsertedAt),
|
|
41
|
+
isCreateMode,
|
|
42
|
+
]);
|
|
36
43
|
return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
|
|
37
44
|
}
|
|
@@ -76,7 +76,7 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
76
76
|
const schedule = ctx.scheduleAfterCommit;
|
|
77
77
|
const release = async () => {
|
|
78
78
|
await releaseDraftFileRefs(keys, async (key) => {
|
|
79
|
-
await files.
|
|
79
|
+
await files.refFromFileRef({ storageKey: key }).delete();
|
|
80
80
|
}, log);
|
|
81
81
|
};
|
|
82
82
|
if (schedule) {
|
|
@@ -210,9 +210,12 @@ async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
|
|
|
210
210
|
// both behind.
|
|
211
211
|
await transaction(db, async (tx) => {
|
|
212
212
|
await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_RUN_LOCK_NAMESPACE, JSON.stringify([key.tenantId, key.jobName, key.subject]));
|
|
213
|
-
|
|
213
|
+
// Read under the lock: the run that finishes last in lock order must also carry the
|
|
214
|
+
// latest finishedAt, otherwise a stale timestamp makes it the row that gets pruned.
|
|
215
|
+
const finishedAt = Temporal.Now.instant();
|
|
216
|
+
await upsertOnConflict(tx, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt, updatedAt: finishedAt }, {
|
|
214
217
|
conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
|
|
215
|
-
update: { status: result, finishedAt
|
|
218
|
+
update: { status: result, finishedAt, updatedAt: finishedAt },
|
|
216
219
|
});
|
|
217
220
|
await deleteMany(tx, tenantJobRunsTable, {
|
|
218
221
|
...key,
|
|
@@ -3,7 +3,21 @@ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import type { MetricDefinition, MetricFilterResolver } from "./types.js";
|
|
5
5
|
export declare const RESERVED_PAYLOAD_KEYS: readonly string[];
|
|
6
|
-
|
|
6
|
+
declare const INTEGER_RANGES: {
|
|
7
|
+
readonly smallint: {
|
|
8
|
+
readonly min: -32768;
|
|
9
|
+
readonly max: 32767;
|
|
10
|
+
};
|
|
11
|
+
readonly integer: {
|
|
12
|
+
readonly min: -2147483648;
|
|
13
|
+
readonly max: 2147483647;
|
|
14
|
+
};
|
|
15
|
+
readonly bigint: {
|
|
16
|
+
readonly min: number;
|
|
17
|
+
readonly max: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export type FilterValueKind = "uuid" | "number" | "string" | keyof typeof INTEGER_RANGES;
|
|
7
21
|
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
8
22
|
export declare function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined;
|
|
9
23
|
export type MetricFilterEntry = {
|
|
@@ -17,3 +31,4 @@ export declare function metricFilterPayloadShape(metric: MetricDefinition): Reco
|
|
|
17
31
|
/** Column-equality filters only; resolver filters need resolveMetricFilterWhere. */
|
|
18
32
|
export declare function metricFilterWhere(metric: MetricDefinition, payload: Readonly<Record<string, unknown>>): WhereObject;
|
|
19
33
|
export declare function resolveMetricFilterWhere(metric: MetricDefinition, payload: Readonly<Record<string, unknown>>, ctx: HandlerContext): Promise<WhereObject>;
|
|
34
|
+
export {};
|
package/dist/metrics/filters.js
CHANGED
|
@@ -3,17 +3,23 @@ import * as z from "zod";
|
|
|
3
3
|
export const RESERVED_PAYLOAD_KEYS = ["range", "timeZone", "tenantId"];
|
|
4
4
|
// Dashboard select options always send strings.
|
|
5
5
|
const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
|
|
6
|
+
const INTEGER_STRING = /^-?\d+$/;
|
|
6
7
|
const MAX_FILTER_STRING_LENGTH = 200;
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
const INTEGER_RANGES = {
|
|
9
|
+
smallint: { min: -32_768, max: 32_767 },
|
|
10
|
+
integer: { min: -2_147_483_648, max: 2_147_483_647 },
|
|
11
|
+
// bigint is bound as a JS number, so only the exactly representable range is accepted.
|
|
12
|
+
bigint: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
|
|
13
|
+
};
|
|
14
|
+
const PG_INTEGER_KINDS = {
|
|
15
|
+
smallint: "smallint",
|
|
16
|
+
int2: "smallint",
|
|
17
|
+
integer: "integer",
|
|
18
|
+
int4: "integer",
|
|
19
|
+
serial: "integer",
|
|
20
|
+
bigint: "bigint",
|
|
21
|
+
bigserial: "bigint",
|
|
22
|
+
};
|
|
17
23
|
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
18
24
|
export function filterValueKindOf(pgType) {
|
|
19
25
|
if (pgType === undefined)
|
|
@@ -22,7 +28,10 @@ export function filterValueKindOf(pgType) {
|
|
|
22
28
|
return "uuid";
|
|
23
29
|
if (pgType === "text")
|
|
24
30
|
return "string";
|
|
25
|
-
|
|
31
|
+
const integerKind = PG_INTEGER_KINDS[pgType];
|
|
32
|
+
if (integerKind !== undefined)
|
|
33
|
+
return integerKind;
|
|
34
|
+
if (pgType === "double precision" || pgType.startsWith("numeric"))
|
|
26
35
|
return "number";
|
|
27
36
|
return undefined;
|
|
28
37
|
}
|
|
@@ -31,9 +40,16 @@ export function metricFilterEntries(metric) {
|
|
|
31
40
|
? { payloadKey, column: spec }
|
|
32
41
|
: { payloadKey, column: spec.column, resolver: spec });
|
|
33
42
|
}
|
|
43
|
+
function integerValueSchema(range) {
|
|
44
|
+
const bounded = z.number().int().min(range.min).max(range.max);
|
|
45
|
+
return z.union([bounded, z.string().regex(INTEGER_STRING).transform(Number).pipe(bounded)]);
|
|
46
|
+
}
|
|
34
47
|
function filterValueSchema(kind) {
|
|
35
48
|
if (kind === "uuid")
|
|
36
49
|
return z.uuid();
|
|
50
|
+
if (kind === "smallint" || kind === "integer" || kind === "bigint") {
|
|
51
|
+
return integerValueSchema(INTEGER_RANGES[kind]);
|
|
52
|
+
}
|
|
37
53
|
if (kind === "number")
|
|
38
54
|
return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
|
|
39
55
|
return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import { transaction, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { runnerCanBeginTransaction, transaction, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
|
-
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
6
|
import { PAT_REVOKED_AGGREGATE_TYPE, PAT_REVOKED_EVENT_QN, patRevokedSchema, } from "./pat-revoked-event.js";
|
|
8
7
|
import { apiTokenTable } from "./schema/api-token.js";
|
|
9
|
-
// A pool has begin(); a tx/savepoint handle does not and is already atomic.
|
|
10
|
-
function canBeginTransaction(db) {
|
|
11
|
-
return typeof propertyOf(db, "begin") === "function";
|
|
12
|
-
}
|
|
13
8
|
// Cross-tenant revoke: password-change and MFA-enable/disable are account-
|
|
14
9
|
// level security events, not scoped to one tenant. Mirrors sessions'
|
|
15
10
|
// sessionMassRevoker (session-callbacks.ts) which passes the boot-time
|
|
@@ -17,7 +12,8 @@ function canBeginTransaction(db) {
|
|
|
17
12
|
export async function revokeAllPatTokensForUser(db, userId) {
|
|
18
13
|
// Update and event commit together: otherwise a failed append leaves the
|
|
19
14
|
// tokens revoked with no event, and a retry matches no live row.
|
|
20
|
-
|
|
15
|
+
// A pool has begin(); a tx/savepoint handle does not and is already atomic.
|
|
16
|
+
return runnerCanBeginTransaction(db)
|
|
21
17
|
? transaction(db, (tx) => revokeAndAppend(tx, userId))
|
|
22
18
|
: revokeAndAppend(db, userId);
|
|
23
19
|
}
|
|
@@ -16,7 +16,9 @@ export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
|
|
|
16
16
|
access,
|
|
17
17
|
handler: async (event, ctx) => {
|
|
18
18
|
const secrets = requireSecretsContext(ctx, "secrets:write:delete");
|
|
19
|
-
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key
|
|
19
|
+
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key, {
|
|
20
|
+
allowUndeclared: true,
|
|
21
|
+
});
|
|
20
22
|
if (!gate.ok)
|
|
21
23
|
return gate.failure;
|
|
22
24
|
const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
|
|
@@ -7,7 +7,9 @@ export type SecretKeyWriteCheck = {
|
|
|
7
7
|
readonly ok: false;
|
|
8
8
|
readonly failure: WriteFailure;
|
|
9
9
|
};
|
|
10
|
-
export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string
|
|
10
|
+
export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string, options?: {
|
|
11
|
+
readonly allowUndeclared?: boolean;
|
|
12
|
+
}): SecretKeyWriteCheck;
|
|
11
13
|
export type SecretValueSchemaSource = Pick<Registry, "getSecretKey" | "findSecretNamespace">;
|
|
12
14
|
export declare function invalidSecretValueError(): ValidationError;
|
|
13
15
|
export declare function isInvalidSecretValueError(err: unknown): err is ValidationError;
|
|
@@ -15,11 +15,14 @@ function namespaceAcceptsKey(namespace, key) {
|
|
|
15
15
|
// Only keys declared via r.secret() or under a declared r.secretNamespace()
|
|
16
16
|
// are writable through the API, so a tenant admin cannot plant arbitrary rows
|
|
17
17
|
// next to the feature-owned ones. writeRoles narrows the handler access
|
|
18
|
-
// further for a key or a whole namespace.
|
|
19
|
-
|
|
18
|
+
// further for a key or a whole namespace. Delete passes allowUndeclared: it
|
|
19
|
+
// creates nothing, and legacy rows under since-undeclared keys must stay removable.
|
|
20
|
+
export function checkSecretKeyWrite(registry, userRoles, key, options = {}) {
|
|
20
21
|
const keyDef = registry.getSecretKey(key);
|
|
21
22
|
const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
|
|
22
|
-
if (!keyDef &&
|
|
23
|
+
if (!keyDef &&
|
|
24
|
+
!(namespace && namespaceAcceptsKey(namespace, key)) &&
|
|
25
|
+
options.allowUndeclared !== true) {
|
|
23
26
|
return unknownKeyFailure(key);
|
|
24
27
|
}
|
|
25
28
|
const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;
|