@cosmicdrift/kumiko-bundled-features 0.333.0 → 0.335.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/admin-shell/overview-panels.js +2 -2
- package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
- package/dist/agent-tools/agent-doc-lint.js +33 -3
- package/dist/agent-tools/agent-manifest.js +47 -6
- package/dist/agent-tools/filter-ops.d.ts +2 -0
- package/dist/agent-tools/filter-ops.js +4 -0
- package/dist/agent-tools/tool-catalog.js +1 -4
- package/dist/agent-tools/tool-dispatch.js +1 -4
- package/dist/audit/constants.d.ts +1 -1
- package/dist/audit/constants.js +1 -1
- package/dist/audit/handlers/list.query.js +4 -3
- package/dist/auth-email-password/handlers/change-password.write.js +2 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
- package/dist/auth-email-password/handlers/login.write.js +2 -0
- package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
- package/dist/auth-email-password/i18n.js +2 -0
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-gate.js +6 -8
- package/dist/auth-email-password/web/index.d.ts +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
- package/dist/auth-email-password/web/login-screen.js +1 -1
- package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
- package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
- package/dist/auth-email-password/web/session.d.ts +4 -0
- package/dist/auth-email-password/web/session.js +15 -0
- package/dist/auth-mfa/feature.js +3 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
- package/dist/auth-mfa/handlers/verify.write.js +2 -0
- package/dist/auth-mfa/hmac-token-codec.js +3 -0
- package/dist/auth-mfa/mfa-challenge-token.js +4 -0
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-client.js +4 -10
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/checkout-core.d.ts +5 -5
- package/dist/billing-foundation/checkout-core.js +10 -6
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/book-cap-usage.js +15 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-counter/feature.js +3 -2
- package/dist/cap-counter/i18n.d.ts +1 -0
- package/dist/cap-counter/i18n.js +6 -5
- package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
- package/dist/cap-overview/i18n.js +5 -0
- package/dist/cap-overview/web/cap-cards-panel.js +4 -1
- package/dist/cap-overview/web/cap-usage-bar.js +1 -1
- package/dist/channel-email/email-channel.js +12 -1
- package/dist/channel-in-app/tables.js +2 -6
- package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
- package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -2
- package/dist/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/table.d.ts +6 -0
- package/dist/config/web/i18n.js +13 -4
- package/dist/config/write-helpers.d.ts +1 -1
- package/dist/config/write-helpers.js +11 -5
- package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
- package/dist/custom-fields/constants.js +1 -4
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/types.js +7 -0
- package/dist/delivery/unsubscribe.js +5 -11
- package/dist/derivatives-sharp/render.js +27 -9
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/document-ingest-foundation/feature.js +5 -5
- package/dist/document-ingest-foundation/providers.d.ts +1 -0
- package/dist/document-ingest-foundation/providers.js +6 -0
- package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
- package/dist/document-ingest-foundation/write-document-extract.js +10 -0
- package/dist/feature-toggles/global-feature-state-table.js +2 -7
- package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
- package/dist/feature-toggles/toggle-sync-signal.js +3 -2
- package/dist/file-provider-s3/feature.js +1 -1
- package/dist/files-tenant-data/index.js +3 -2
- package/dist/folders/entity.d.ts +8 -0
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/form-draft/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +7 -8
- package/dist/form-draft/handlers/get.query.js +7 -1
- package/dist/foundation-shared/host-allowlist.d.ts +4 -0
- package/dist/foundation-shared/host-allowlist.js +14 -0
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
- package/dist/foundation-shared/mail-host-policy.js +3 -13
- package/dist/inbound-mail-foundation/connect-routes.js +7 -5
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
- package/dist/jobs/handlers/list.query.d.ts +7 -2
- package/dist/jobs/handlers/list.query.js +9 -2
- package/dist/jobs/handlers/retry.write.js +3 -2
- package/dist/jobs/handlers/tenant-failures.query.js +3 -9
- package/dist/jobs/job-run-logger.js +18 -2
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/tenant-job-failure-table.js +2 -6
- package/dist/ledger/entity.d.ts +16 -3
- package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
- package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
- package/dist/ledger/subject-reference.d.ts +4 -0
- package/dist/ledger/subject-reference.js +7 -0
- package/dist/legal-pages/feature.js +8 -1
- package/dist/managed-pages/handlers/branding.query.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/handlers/add-note.write.js +4 -9
- package/dist/notes-history-user-data/hooks.js +8 -1
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.js +2 -2
- package/dist/personal-access-tokens/handlers/create.write.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
- package/dist/personal-access-tokens/i18n.js +12 -13
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/schema/api-token.js +2 -1
- package/dist/personal-access-tokens/screens.js +5 -1
- package/dist/rate-limiting/feature.js +2 -0
- package/dist/rate-limiting/handlers/bucket-access.js +15 -7
- package/dist/rate-limiting/i18n.d.ts +10 -0
- package/dist/rate-limiting/i18n.js +10 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/secrets/table.d.ts +1 -0
- package/dist/seo/handlers/seo-config.query.js +1 -1
- package/dist/sessions/constants.d.ts +1 -0
- package/dist/sessions/constants.js +1 -0
- package/dist/sessions/feature.js +5 -3
- package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
- package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
- package/dist/sessions/handlers/revoke.write.js +2 -2
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/schema/user-session.js +2 -1
- package/dist/sessions/screens.js +5 -1
- package/dist/sessions/session-callbacks.js +2 -2
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/lockout-counter.js +1 -1
- package/dist/shared/parent-visibility.d.ts +5 -0
- package/dist/shared/parent-visibility.js +17 -1
- package/dist/shared/row-bound-grant.js +20 -9
- package/dist/shared/single-use-token-store.js +5 -13
- package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
- package/dist/step-dispatcher/webhook-runner.js +18 -17
- package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
- package/dist/subscription-mollie/plugin-methods.js +7 -1
- package/dist/subscription-mollie/verify-webhook.js +6 -0
- package/dist/subscription-stripe/feature.js +3 -3
- package/dist/subscription-stripe/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- package/dist/tags/entity.d.ts +9 -0
- package/dist/tags/handlers/assign-tag.write.js +4 -10
- package/dist/tags/handlers/remove-tag.write.js +4 -11
- package/dist/template-resolver/api.d.ts +2 -1
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
- package/dist/template-resolver/handlers/by-slug.query.js +2 -2
- package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
- package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
- package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
- package/dist/template-resolver/handlers/collection-shared.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/list.query.js +1 -1
- package/dist/template-resolver/seeding.js +41 -39
- package/dist/template-resolver/table.d.ts +26 -13
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +16 -9
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver-user-data/hooks.js +1 -1
- package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
- package/dist/tenant/cancel-pending-invitation.js +22 -0
- package/dist/tenant/feature.js +4 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +17 -4
- package/dist/tenant/handlers/remove-member.write.js +9 -14
- package/dist/tenant/handlers/team-list.query.js +2 -2
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +13 -3
- package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +28 -12
- package/dist/tenant/i18n.js +4 -0
- package/dist/tenant/index.d.ts +1 -1
- package/dist/tenant/index.js +1 -1
- package/dist/tenant/invitation-table.d.ts +8 -2
- package/dist/tenant/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +8 -2
- package/dist/tenant/seeding.d.ts +4 -0
- package/dist/tenant/seeding.js +9 -2
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +27 -1
- package/dist/tenant-lifecycle/feature.js +2 -1
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/tenant-settings/feature.js +9 -6
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/feature.js +3 -2
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/command-schemas.d.ts +2 -2
- package/dist/user/handlers/create.write.d.ts +1 -1
- package/dist/user/handlers/create.write.js +8 -2
- package/dist/user/handlers/update.write.d.ts +1 -1
- package/dist/user/handlers/update.write.js +8 -2
- package/dist/user/schema/user.d.ts +14 -2
- package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
- package/dist/user-data-rights/feature.js +22 -9
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
- package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
- package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
- package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
- package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
- package/dist/user-data-rights/run-export-jobs.js +74 -41
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/user-data-rights/run-user-export.js +8 -4
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +8 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
- package/dist/user-profile/feature.js +6 -1
- package/dist/user-profile/handlers/change-email.write.js +2 -0
- package/dist/workflow-runner/db/queries/due-runs.js +6 -9
- package/dist/workflow-runner/event-subscriber.js +29 -9
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
- package/dist/workflow-runner/pending-projection.js +8 -1
- package/package.json +11 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +12 -0
- package/src/delivery/changes.json +7 -0
- package/src/derivatives-sharp/changes.json +11 -0
- package/src/document-ingest-foundation/changes.json +12 -0
- package/src/inbound-mail-foundation/changes.json +12 -0
- package/src/jobs/changes.json +13 -0
- package/src/legal-pages/changes.json +8 -1
- package/src/managed-pages/changes.json +5 -0
- package/src/metrics/changes.json +6 -0
- package/src/rate-limiting/changes.json +5 -0
- package/src/secrets/changes.json +7 -0
- package/src/sessions/changes.json +5 -0
- package/src/shared/changes.json +6 -0
- package/src/step-dispatcher/changes.json +5 -0
- package/src/subscription-mollie/changes.json +7 -0
- package/src/subscription-stripe/changes.json +11 -0
- package/src/template-resolver/changes.json +7 -0
- package/src/tenant/changes.json +29 -0
- package/src/user-data-rights/changes.json +22 -0
- package/src/workflow-runner/changes.json +6 -0
|
@@ -41,7 +41,7 @@ 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, } from "@cosmicdrift/kumiko-framework/db";
|
|
43
43
|
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
44
|
-
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
45
|
import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
|
|
46
46
|
import { runInSubTransaction } from "../shared/index.js";
|
|
47
47
|
import { computeCutoff } from "./keep-for.js";
|
|
@@ -204,22 +204,62 @@ function isFileRefBoundElsewhere(fileRef, entityName, rowId) {
|
|
|
204
204
|
return true;
|
|
205
205
|
return false;
|
|
206
206
|
}
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
for (const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
207
|
+
// Every implicit entity table with file/image fields: an unbound fileRef
|
|
208
|
+
// (POST /files without entityType/entityId) can sit in single fields of
|
|
209
|
+
// several entity types, so the sharing check must look beyond the purged
|
|
210
|
+
// row's own table.
|
|
211
|
+
function collectSingleFileHolders(registry) {
|
|
212
|
+
const holders = [];
|
|
213
|
+
for (const proj of registry.getAllProjections().values()) {
|
|
214
|
+
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
|
|
215
|
+
continue;
|
|
216
|
+
const entity = registry.getEntity(proj.source);
|
|
217
|
+
if (!entity)
|
|
218
|
+
continue;
|
|
219
|
+
const { singleFields } = planFileFields(entity.fields);
|
|
220
|
+
if (singleFields.length === 0)
|
|
221
|
+
continue;
|
|
222
|
+
const table = proj.table; // @cast-boundary column-presence probe
|
|
223
|
+
holders.push({
|
|
224
|
+
table: proj.table,
|
|
225
|
+
tableHasTenantId: table["tenantId"] !== undefined,
|
|
226
|
+
singleFields,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return holders;
|
|
230
|
+
}
|
|
231
|
+
// A single-field fileRef-id can be shared by more than one row, in the SAME
|
|
232
|
+
// table (e.g. a stock-photo re-picked across records) or in another entity's
|
|
233
|
+
// table. Purging its bytes would corrupt the row that isn't being deleted.
|
|
234
|
+
// Bounded by batchLimit rows per run and holderFields queries per fileRef —
|
|
235
|
+
// the same per-row event-cost tradeoff as the rest of this cron (see header).
|
|
236
|
+
async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId, fileRefId) {
|
|
237
|
+
for (const holder of holders) {
|
|
238
|
+
for (const field of holder.singleFields) {
|
|
239
|
+
const where = { [field]: fileRefId };
|
|
240
|
+
if (holder.table === ownTable)
|
|
241
|
+
where["id"] = { ne: rowId };
|
|
242
|
+
if (holder.tableHasTenantId)
|
|
243
|
+
where["tenantId"] = tenantId;
|
|
244
|
+
const others = await selectMany(db, holder.table, where, { limit: 1 });
|
|
245
|
+
if (others.length > 0)
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
220
248
|
}
|
|
221
249
|
return false;
|
|
222
250
|
}
|
|
251
|
+
// Throws (unsafe key, missing list permission) must fail this row closed,
|
|
252
|
+
// not abort the whole tenant run.
|
|
253
|
+
async function deleteFileFailingClosed(key, store, logContext) {
|
|
254
|
+
try {
|
|
255
|
+
return await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
259
|
+
console.warn(`[data-retention:hardDelete] ${logContext} storage cleanup threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
260
|
+
return [key];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
223
263
|
// hardDelete for ONE row of an entity that has file/image/files/images
|
|
224
264
|
// fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
|
|
225
265
|
// their fileRef rows, then the entity row itself — bytes before rows so a
|
|
@@ -251,7 +291,7 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
251
291
|
for (const fileRef of fileRefRows) {
|
|
252
292
|
if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId))
|
|
253
293
|
continue;
|
|
254
|
-
const shared = await isFileRefSharedByOtherRow(args.db, args.
|
|
294
|
+
const shared = await isFileRefSharedByOtherRow(args.db, args.fileHolders, args.table, args.tenantId, rowId, String(fileRef["id"]));
|
|
255
295
|
if (shared)
|
|
256
296
|
continue;
|
|
257
297
|
toDelete.push(fileRef);
|
|
@@ -270,7 +310,7 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
270
310
|
const key = fileRef["storageKey"];
|
|
271
311
|
if (typeof key !== "string" || key.length === 0)
|
|
272
312
|
continue;
|
|
273
|
-
const failedKeys = await
|
|
313
|
+
const failedKeys = await deleteFileFailingClosed(key, store, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
|
|
274
314
|
if (failedKeys.length > 0) {
|
|
275
315
|
anyFailed = true;
|
|
276
316
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
@@ -295,9 +335,6 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
295
335
|
// each skip reason at most once per entity per run (repeat skips would just
|
|
296
336
|
// be noise; the per-row failure is already warned above).
|
|
297
337
|
async function purgeHardDeleteRowsWithFiles(args) {
|
|
298
|
-
const rows = await selectMany(args.db, args.table, args.where, {
|
|
299
|
-
limit: args.batchLimit,
|
|
300
|
-
});
|
|
301
338
|
const reasonsSeen = new Set();
|
|
302
339
|
const onSkip = (reason) => {
|
|
303
340
|
// skip: reason already reported for this entity in this run
|
|
@@ -306,28 +343,44 @@ async function purgeHardDeleteRowsWithFiles(args) {
|
|
|
306
343
|
reasonsSeen.add(reason);
|
|
307
344
|
args.skipped.push({ entityName: args.entityName, reason });
|
|
308
345
|
};
|
|
346
|
+
// id-cursor paging: rows skipped (missing storage, failed delete) stay in the
|
|
347
|
+
// table, so an unordered limit-page would re-fetch the same stuck rows every
|
|
348
|
+
// run and starve every row behind them. batchLimit bounds the DELETES.
|
|
309
349
|
let count = 0;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
entityName: args.entityName,
|
|
317
|
-
entity: args.entity,
|
|
318
|
-
row,
|
|
319
|
-
plan: args.plan,
|
|
320
|
-
files: args.files,
|
|
321
|
-
entityExecutor: args.entityExecutor,
|
|
322
|
-
fileRefExecutor: args.fileRefExecutor,
|
|
323
|
-
systemUser: args.systemUser,
|
|
324
|
-
tdb: args.tdb,
|
|
325
|
-
kms: args.kms,
|
|
326
|
-
registry: args.registry,
|
|
327
|
-
onSkip,
|
|
350
|
+
let cursor = null;
|
|
351
|
+
while (count < args.batchLimit) {
|
|
352
|
+
const pageWhere = cursor === null ? args.where : { ...args.where, id: { gt: cursor } };
|
|
353
|
+
const rows = await selectMany(args.db, args.table, pageWhere, {
|
|
354
|
+
limit: args.batchLimit,
|
|
355
|
+
orderBy: { col: "id" },
|
|
328
356
|
});
|
|
329
|
-
if (
|
|
330
|
-
|
|
357
|
+
if (rows.length === 0)
|
|
358
|
+
break;
|
|
359
|
+
for (const row of rows) {
|
|
360
|
+
cursor = row["id"]; // @cast-boundary db-row
|
|
361
|
+
const ok = await purgeHardDeleteRowWithFiles({
|
|
362
|
+
db: args.db,
|
|
363
|
+
table: args.table,
|
|
364
|
+
fileHolders: args.fileHolders,
|
|
365
|
+
tenantId: args.tenantId,
|
|
366
|
+
entityName: args.entityName,
|
|
367
|
+
entity: args.entity,
|
|
368
|
+
row,
|
|
369
|
+
plan: args.plan,
|
|
370
|
+
files: args.files,
|
|
371
|
+
entityExecutor: args.entityExecutor,
|
|
372
|
+
fileRefExecutor: args.fileRefExecutor,
|
|
373
|
+
systemUser: args.systemUser,
|
|
374
|
+
tdb: args.tdb,
|
|
375
|
+
kms: args.kms,
|
|
376
|
+
registry: args.registry,
|
|
377
|
+
onSkip,
|
|
378
|
+
});
|
|
379
|
+
if (ok)
|
|
380
|
+
count++;
|
|
381
|
+
if (count >= args.batchLimit)
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
331
384
|
}
|
|
332
385
|
return count;
|
|
333
386
|
}
|
|
@@ -394,6 +447,7 @@ export async function runRetentionCleanup(args) {
|
|
|
394
447
|
// filter shrinks the set). A single batched forget/delete event is a follow-up.
|
|
395
448
|
const systemUser = createSystemUser(tenantId);
|
|
396
449
|
const tdb = createTenantDb(db, tenantId, "system");
|
|
450
|
+
const fileHolders = collectSingleFileHolders(registry);
|
|
397
451
|
// Pre-load every override row for this tenant ONCE — N entities × M
|
|
398
452
|
// tenants would otherwise mean one fetchOne per entity per tenant, even
|
|
399
453
|
// though the whole set for a tenant is a single small, indexed read.
|
|
@@ -458,7 +512,7 @@ export async function runRetentionCleanup(args) {
|
|
|
458
512
|
hardDeleted += await purgeHardDeleteRowsWithFiles({
|
|
459
513
|
db,
|
|
460
514
|
table: proj.table,
|
|
461
|
-
|
|
515
|
+
fileHolders,
|
|
462
516
|
where,
|
|
463
517
|
batchLimit,
|
|
464
518
|
tenantId,
|
|
@@ -495,10 +549,12 @@ export async function runRetentionCleanup(args) {
|
|
|
495
549
|
case "blockDelete": {
|
|
496
550
|
const targets = await resolveAnonymizeTargets(entity.fields);
|
|
497
551
|
if (!targets) {
|
|
498
|
-
// blockDelete without anonymize fields is a pure hold
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
552
|
+
// blockDelete without anonymize fields is a pure hold only while the
|
|
553
|
+
// entity has no field that CAN carry anonymize: a subjectRef-only
|
|
554
|
+
// person link outlives the hold and is cut by the user-data forget
|
|
555
|
+
// hook. A personal field lacking its anonymize function would stay
|
|
556
|
+
// stored past the hold, so that case is still reported.
|
|
557
|
+
if (policy.strategy === "anonymize" || entityHasAnonymizableSubjectField(entity.fields)) {
|
|
502
558
|
skipped.push({ entityName, reason: "missing_anonymize_fields" });
|
|
503
559
|
}
|
|
504
560
|
break;
|
|
@@ -9,6 +9,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
|
|
|
9
9
|
readonly filterable?: boolean;
|
|
10
10
|
readonly encrypted?: boolean;
|
|
11
11
|
readonly sensitive?: boolean;
|
|
12
|
+
readonly writeOnly?: boolean;
|
|
12
13
|
readonly readAsInstruction?: boolean;
|
|
13
14
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
14
15
|
readonly default?: string;
|
|
@@ -45,6 +46,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
|
|
|
45
46
|
readonly filterable?: boolean;
|
|
46
47
|
readonly encrypted?: boolean;
|
|
47
48
|
readonly sensitive?: boolean;
|
|
49
|
+
readonly writeOnly?: boolean;
|
|
48
50
|
readonly readAsInstruction?: boolean;
|
|
49
51
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
50
52
|
readonly default?: string;
|
|
@@ -67,6 +69,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
|
|
|
67
69
|
readonly filterable?: boolean;
|
|
68
70
|
readonly encrypted?: boolean;
|
|
69
71
|
readonly sensitive?: boolean;
|
|
72
|
+
readonly writeOnly?: boolean;
|
|
70
73
|
readonly readAsInstruction?: boolean;
|
|
71
74
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
72
75
|
readonly default?: string;
|
|
@@ -103,6 +106,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
|
|
|
103
106
|
readonly filterable?: boolean;
|
|
104
107
|
readonly encrypted?: boolean;
|
|
105
108
|
readonly sensitive?: boolean;
|
|
109
|
+
readonly writeOnly?: boolean;
|
|
106
110
|
readonly readAsInstruction?: boolean;
|
|
107
111
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
108
112
|
readonly default?: string;
|
|
@@ -12,6 +12,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
|
|
|
12
12
|
readonly filterable?: boolean;
|
|
13
13
|
readonly encrypted?: boolean;
|
|
14
14
|
readonly sensitive?: boolean;
|
|
15
|
+
readonly writeOnly?: boolean;
|
|
15
16
|
readonly readAsInstruction?: boolean;
|
|
16
17
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
17
18
|
readonly default?: string;
|
|
@@ -32,6 +33,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
|
|
|
32
33
|
readonly filterable?: boolean;
|
|
33
34
|
readonly encrypted?: boolean;
|
|
34
35
|
readonly sensitive?: boolean;
|
|
36
|
+
readonly writeOnly?: boolean;
|
|
35
37
|
readonly readAsInstruction?: boolean;
|
|
36
38
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
37
39
|
readonly default?: string;
|
|
@@ -52,6 +54,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
|
|
|
52
54
|
readonly filterable?: boolean;
|
|
53
55
|
readonly encrypted?: boolean;
|
|
54
56
|
readonly sensitive?: boolean;
|
|
57
|
+
readonly writeOnly?: boolean;
|
|
55
58
|
readonly readAsInstruction?: boolean;
|
|
56
59
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
57
60
|
readonly default?: string;
|
|
@@ -78,6 +81,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
|
|
|
78
81
|
readonly filterable?: boolean;
|
|
79
82
|
readonly encrypted?: boolean;
|
|
80
83
|
readonly sensitive?: boolean;
|
|
84
|
+
readonly writeOnly?: boolean;
|
|
81
85
|
readonly readAsInstruction?: boolean;
|
|
82
86
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
83
87
|
readonly default?: string;
|
|
@@ -98,6 +102,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
|
|
|
98
102
|
readonly filterable?: boolean;
|
|
99
103
|
readonly encrypted?: boolean;
|
|
100
104
|
readonly sensitive?: boolean;
|
|
105
|
+
readonly writeOnly?: boolean;
|
|
101
106
|
readonly readAsInstruction?: boolean;
|
|
102
107
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
103
108
|
readonly default?: string;
|
|
@@ -118,6 +123,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
|
|
|
118
123
|
readonly filterable?: boolean;
|
|
119
124
|
readonly encrypted?: boolean;
|
|
120
125
|
readonly sensitive?: boolean;
|
|
126
|
+
readonly writeOnly?: boolean;
|
|
121
127
|
readonly readAsInstruction?: boolean;
|
|
122
128
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
123
129
|
readonly default?: string;
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -23,7 +23,8 @@ export type RenderedMessage = {
|
|
|
23
23
|
readonly html: string;
|
|
24
24
|
readonly subject: string;
|
|
25
25
|
};
|
|
26
|
-
export
|
|
26
|
+
export declare const DELIVERY_CHANNEL_MODES: readonly ["inline", "queued"];
|
|
27
|
+
export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
|
|
27
28
|
export type DeliveryChannel = {
|
|
28
29
|
readonly name: string;
|
|
29
30
|
readonly mode: DeliveryChannelMode;
|
package/dist/delivery/types.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
|
|
2
|
+
// `mode` decides how the delivery-service dispatches a channel:
|
|
3
|
+
// inline — sent synchronously inside notify() (inApp: DB insert + SSE).
|
|
4
|
+
// queued — sent asynchronously via the delivery.send job; channels with a
|
|
5
|
+
// render() additionally run through delivery.render first.
|
|
6
|
+
export const DELIVERY_CHANNEL_MODES = ["inline", "queued"];
|
|
2
7
|
export function isDeliveryChannelPlugin(o) {
|
|
3
8
|
return (typeof o === "object" &&
|
|
4
9
|
o !== null &&
|
|
5
10
|
"mode" in o &&
|
|
11
|
+
DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
|
|
12
|
+
(!("render" in o) || o.render === undefined || typeof o.render === "function") &&
|
|
6
13
|
"resolve" in o &&
|
|
7
14
|
typeof o.resolve === "function" &&
|
|
8
15
|
"send" in o &&
|
|
@@ -213,13 +213,7 @@ function confirmationPage(token) {
|
|
|
213
213
|
// One-click clients put the token in the query string; only the
|
|
214
214
|
// confirmation-page form submits it in the body.
|
|
215
215
|
function tokenFromPostRequest(request) {
|
|
216
|
-
|
|
217
|
-
if (contentType?.includes("application/x-www-form-urlencoded")) {
|
|
218
|
-
const fromBody = new URLSearchParams(request.rawBody).get("token");
|
|
219
|
-
if (fromBody)
|
|
220
|
-
return fromBody;
|
|
221
|
-
}
|
|
222
|
-
return request.query["token"];
|
|
216
|
+
return tokenFromRequestBody(request) ?? request.query["token"];
|
|
223
217
|
}
|
|
224
218
|
// Resubscribe is deliberately NOT reachable via the query string — a mail
|
|
225
219
|
// client's link prefetcher (or a scanner) following an unsubscribe link
|
|
@@ -227,10 +221,10 @@ function tokenFromPostRequest(request) {
|
|
|
227
221
|
// convention is built to avoid; resubscribe carries the same risk in
|
|
228
222
|
// reverse (a prefetch that undoes a real opt-out) and gets no query-param
|
|
229
223
|
// fallback at all.
|
|
230
|
-
function
|
|
224
|
+
function tokenFromRequestBody(request) {
|
|
231
225
|
const contentType = request.headers["content-type"];
|
|
232
226
|
if (contentType?.includes("application/x-www-form-urlencoded")) {
|
|
233
|
-
return new URLSearchParams(request.rawBody).get("token")
|
|
227
|
+
return new URLSearchParams(request.rawBody).get("token") || undefined;
|
|
234
228
|
}
|
|
235
229
|
if (contentType?.includes("application/json")) {
|
|
236
230
|
try {
|
|
@@ -278,13 +272,13 @@ export function createUnsubscribeRoutes(options) {
|
|
|
278
272
|
},
|
|
279
273
|
});
|
|
280
274
|
// Undo direction: token only from the JSON/form body, never the query
|
|
281
|
-
// (see
|
|
275
|
+
// (see tokenFromRequestBody) — no GET confirmation-page variant,
|
|
282
276
|
// this is a one-shot POST from the unsubscribe-page's "Undo" button.
|
|
283
277
|
const resubscribeRoute = signatureRoute({
|
|
284
278
|
method: "POST",
|
|
285
279
|
path: DELIVERY_RESUBSCRIBE_PATH,
|
|
286
280
|
entry: "signature",
|
|
287
|
-
verify: async (request) => verifyUnsubscribeToken(
|
|
281
|
+
verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
|
|
288
282
|
handler: async (c, verified, deps) => {
|
|
289
283
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
290
284
|
if (outcome === "limit_reached") {
|
|
@@ -124,24 +124,40 @@ function overlayPosition(gravity, marginPx, outputWidth, outputHeight, layerWidt
|
|
|
124
124
|
// fraction of the output is a fraction of what the caller actually gets,
|
|
125
125
|
// regardless of the source's own dimensions or `fit` crop.
|
|
126
126
|
async function applyOverlays(pipeline, layers) {
|
|
127
|
-
|
|
127
|
+
// PNG intermediate: a bare toBuffer() re-encodes in the source format, so a
|
|
128
|
+
// JPEG source would gain an extra lossy generation before applyEncoder runs.
|
|
129
|
+
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
|
|
128
130
|
const composites = [];
|
|
129
131
|
for (const layer of layers) {
|
|
132
|
+
const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
|
|
133
|
+
// The QR is square, so the output height bounds its side just like the width does.
|
|
134
|
+
const qrSide = Math.min(targetWidth, info.height);
|
|
135
|
+
if (layer.kind === "qr" && qrSide < MIN_QR_PIXEL_WIDTH) {
|
|
136
|
+
throw new Error(`derivatives-sharp: qr overlay would render at ${qrSide}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
|
|
137
|
+
}
|
|
138
|
+
// Rendered at its final size so the modules stay hard-edged; resampling a
|
|
139
|
+
// small default-size QR to a non-integer module size blurs them.
|
|
130
140
|
const layerBytes = layer.kind === "qr"
|
|
131
|
-
? await QRCode.toBuffer(layer.data, {
|
|
141
|
+
? await QRCode.toBuffer(layer.data, {
|
|
142
|
+
width: qrSide,
|
|
143
|
+
margin: 1,
|
|
144
|
+
errorCorrectionLevel: "M",
|
|
145
|
+
})
|
|
132
146
|
: Buffer.from(layer.imageBase64, "base64");
|
|
133
147
|
const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
|
|
134
148
|
assertNotSvg(layerMeta.format);
|
|
135
|
-
const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
|
|
136
|
-
if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
|
|
137
|
-
throw new Error(`derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
|
|
138
|
-
}
|
|
139
149
|
// PNG (not the layer's own format) to keep any alpha through the resize.
|
|
140
150
|
// height caps the other axis too — sharp's composite() rejects an input
|
|
141
151
|
// larger than the base image, which a tall/square layer would otherwise
|
|
142
152
|
// hit once widthPct alone drives its width past the output's height.
|
|
143
153
|
const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
|
|
144
|
-
.resize({
|
|
154
|
+
.resize({
|
|
155
|
+
width: targetWidth,
|
|
156
|
+
height: info.height,
|
|
157
|
+
fit: "inside",
|
|
158
|
+
// Only ever a sub-pixel correction for a QR; keeps module edges crisp.
|
|
159
|
+
kernel: layer.kind === "qr" ? "nearest" : "lanczos3",
|
|
160
|
+
})
|
|
145
161
|
.png()
|
|
146
162
|
.toBuffer({ resolveWithObject: true });
|
|
147
163
|
const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
|
|
@@ -212,7 +228,8 @@ function assertOverlayLayerBounds(layer) {
|
|
|
212
228
|
if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
|
|
213
229
|
throw new Error(`derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`);
|
|
214
230
|
}
|
|
215
|
-
if (layer.marginPct !== undefined &&
|
|
231
|
+
if (layer.marginPct !== undefined &&
|
|
232
|
+
(!Number.isFinite(layer.marginPct) || layer.marginPct < 0 || layer.marginPct >= 0.5)) {
|
|
216
233
|
throw new Error(`derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`);
|
|
217
234
|
}
|
|
218
235
|
if (layer.kind === "qr") {
|
|
@@ -278,7 +295,8 @@ export const renderImage = async (input, spec, sourceMimeType) => {
|
|
|
278
295
|
// the output.
|
|
279
296
|
let pipeline = sharp(input, SHARP_INPUT_OPTIONS).rotate();
|
|
280
297
|
if (spec.blurRegions && spec.blurRegions.length > 0) {
|
|
281
|
-
|
|
298
|
+
// PNG intermediate for the same reason as in applyOverlays (no extra lossy generation).
|
|
299
|
+
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
|
|
282
300
|
const blurred = await applyBlurRegions(data, info.width, info.height, spec.blurRegions);
|
|
283
301
|
pipeline = sharp(blurred, SHARP_INPUT_OPTIONS);
|
|
284
302
|
}
|
|
@@ -20,6 +20,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
20
20
|
readonly filterable?: boolean;
|
|
21
21
|
readonly encrypted?: boolean;
|
|
22
22
|
readonly sensitive?: boolean;
|
|
23
|
+
readonly writeOnly?: boolean;
|
|
23
24
|
readonly readAsInstruction?: boolean;
|
|
24
25
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
25
26
|
readonly default?: string;
|
|
@@ -40,6 +41,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
40
41
|
readonly filterable?: boolean;
|
|
41
42
|
readonly encrypted?: boolean;
|
|
42
43
|
readonly sensitive?: boolean;
|
|
44
|
+
readonly writeOnly?: boolean;
|
|
43
45
|
readonly readAsInstruction?: boolean;
|
|
44
46
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
45
47
|
readonly default?: string;
|
|
@@ -79,6 +81,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
|
|
|
79
81
|
readonly filterable?: boolean;
|
|
80
82
|
readonly encrypted?: boolean;
|
|
81
83
|
readonly sensitive?: boolean;
|
|
84
|
+
readonly writeOnly?: boolean;
|
|
82
85
|
readonly readAsInstruction?: boolean;
|
|
83
86
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
84
87
|
readonly default?: string;
|
|
@@ -99,6 +102,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
|
|
|
99
102
|
readonly filterable?: boolean;
|
|
100
103
|
readonly encrypted?: boolean;
|
|
101
104
|
readonly sensitive?: boolean;
|
|
105
|
+
readonly writeOnly?: boolean;
|
|
102
106
|
readonly readAsInstruction?: boolean;
|
|
103
107
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
104
108
|
readonly default?: string;
|
|
@@ -11,7 +11,7 @@ import { validateDocumentIngestProviderWiring } from "./boot-checks.js";
|
|
|
11
11
|
import { documentExtractEntity } from "./entity.js";
|
|
12
12
|
import { DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
|
|
13
13
|
import { forgetExtractOnFileRefDeletedHook, forgetExtractOnFileRefForgottenHook, forgetOrphanedDocumentExtractHook, } from "./forget-extract-with-file-ref.js";
|
|
14
|
-
import { EXT_DOCUMENT_INGEST_PROVIDER, resolveDocumentIngestProviders } from "./providers.js";
|
|
14
|
+
import { EXT_DOCUMENT_INGEST_PROVIDER, resolveDocumentIngestProviders, UNCLAIMED_PROVIDER_SENTINEL, } from "./providers.js";
|
|
15
15
|
import { documentExtractTenantDestroyHook } from "./tenant-destroy-hook.js";
|
|
16
16
|
const FEATURE_NAME = "document-ingest-foundation";
|
|
17
17
|
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
@@ -123,10 +123,10 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
123
123
|
{
|
|
124
124
|
fromVersion: 1,
|
|
125
125
|
toVersion: 2,
|
|
126
|
-
//
|
|
127
|
-
// upcast v1 row reads as unclaimed
|
|
128
|
-
//
|
|
129
|
-
transform: { default: { provider:
|
|
126
|
+
// The sentinel can never match a real where.provider filter (providers
|
|
127
|
+
// may not register under it) — an upcast v1 row reads as unclaimed
|
|
128
|
+
// instead of silently misrouting to a currently mounted provider.
|
|
129
|
+
transform: { default: { provider: UNCLAIMED_PROVIDER_SENTINEL } },
|
|
130
130
|
},
|
|
131
131
|
],
|
|
132
132
|
});
|
|
@@ -16,6 +16,7 @@ export type ResolvedDocumentIngestProvider = {
|
|
|
16
16
|
readonly name: string;
|
|
17
17
|
readonly maxFileBytes: number;
|
|
18
18
|
};
|
|
19
|
+
export declare const UNCLAIMED_PROVIDER_SENTINEL = "unknown";
|
|
19
20
|
type ExtensionUsages = FeatureDefinition["extensionUsages"];
|
|
20
21
|
export declare function resolveDocumentIngestProviders(usages: ExtensionUsages): ReadonlyMap<string, ResolvedDocumentIngestProvider>;
|
|
21
22
|
export declare function listIngestibleMimeTypes(registry: Registry): readonly string[];
|
|
@@ -9,6 +9,9 @@ export const documentIngestProviderOptionsSchema = z.object({
|
|
|
9
9
|
mimeTypes: z.array(z.string().min(1)).min(1),
|
|
10
10
|
maxFileBytes: z.number().int().positive(),
|
|
11
11
|
});
|
|
12
|
+
// Stamped on upcast v1 documentIngest.requested events; a provider may not
|
|
13
|
+
// claim it, or replayed v1 events would route to that provider.
|
|
14
|
+
export const UNCLAIMED_PROVIDER_SENTINEL = "unknown";
|
|
12
15
|
// Throws on an invalid options shape or two providers claiming the same
|
|
13
16
|
// normalized mimeType — callers decide whether that's boot-fatal.
|
|
14
17
|
export function resolveDocumentIngestProviders(usages) {
|
|
@@ -16,6 +19,9 @@ export function resolveDocumentIngestProviders(usages) {
|
|
|
16
19
|
for (const usage of usages) {
|
|
17
20
|
if (usage.extensionName !== EXT_DOCUMENT_INGEST_PROVIDER)
|
|
18
21
|
continue;
|
|
22
|
+
if (usage.entityName === UNCLAIMED_PROVIDER_SENTINEL) {
|
|
23
|
+
throw new Error(`document-ingest-foundation: provider name "${UNCLAIMED_PROVIDER_SENTINEL}" is reserved for upcast legacy events.`);
|
|
24
|
+
}
|
|
19
25
|
const parsed = documentIngestProviderOptionsSchema.safeParse(usage.options);
|
|
20
26
|
if (!parsed.success) {
|
|
21
27
|
throw new Error(`document-ingest-foundation: provider "${usage.entityName}" registered invalid options via ` +
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import type
|
|
3
|
+
import { type DocumentExtractMeta, type IngestPage } from "./entity.js";
|
|
4
4
|
export type DocumentExtractWriteResult = {
|
|
5
5
|
readonly kind: "written";
|
|
6
6
|
readonly documentExtractId: string;
|
|
7
7
|
} | {
|
|
8
8
|
readonly kind: "skipped";
|
|
9
|
-
readonly reason: "file_ref_deleted";
|
|
9
|
+
readonly reason: "file_ref_deleted" | "already_extracted";
|
|
10
10
|
};
|
|
11
11
|
export declare function writeDocumentExtractForLiveFileRef(input: {
|
|
12
12
|
readonly tenantDb: TenantDb;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the one place that write must go through to stay race-safe against a
|
|
4
4
|
// concurrent fileRef delete/forget — see forget-extract-with-file-ref.ts.
|
|
5
5
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
|
+
import { documentExtractsTable } from "./entity.js";
|
|
6
7
|
import { documentExtractExecutor } from "./executor.js";
|
|
7
8
|
import { isFileRefLive } from "./file-ref-liveness.js";
|
|
8
9
|
import { writeIngestPages } from "./pages.js";
|
|
@@ -13,6 +14,15 @@ export async function writeDocumentExtractForLiveFileRef(input) {
|
|
|
13
14
|
if (!(await isFileRefLive(input.tenantDb, input.fileRefId))) {
|
|
14
15
|
return { kind: "skipped", reason: "file_ref_deleted" };
|
|
15
16
|
}
|
|
17
|
+
// A redelivered documentIngest.requested, or the one a delete→restore race
|
|
18
|
+
// re-requests while the old extract is still standing, must not create a
|
|
19
|
+
// second extract for the same fileRef.
|
|
20
|
+
const existing = await input.tenantDb.selectMany(documentExtractsTable, {
|
|
21
|
+
fileRefId: input.fileRefId,
|
|
22
|
+
});
|
|
23
|
+
if (existing.length > 0) {
|
|
24
|
+
return { kind: "skipped", reason: "already_extracted" };
|
|
25
|
+
}
|
|
16
26
|
const result = await documentExtractExecutor.create({
|
|
17
27
|
fileRefId: input.fileRefId,
|
|
18
28
|
storageKey: input.storageKey,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { boolean, declareGlobalTenancy, instant, integer, table as pgTable, sql, text, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
// Global feature-toggle override state. One row per feature that has ever
|
|
4
4
|
// been explicitly flipped by an operator. Missing row = "no override,
|
|
@@ -26,9 +26,4 @@ export const globalFeatureStateTable = declareGlobalTenancy(pgTable("store_globa
|
|
|
26
26
|
// the pgTable made queries type-check while the migration generator stayed
|
|
27
27
|
// blind to it, failing in prod with "column ... does not exist" while every
|
|
28
28
|
// mocked-stack test stayed green.
|
|
29
|
-
const
|
|
30
|
-
if (!derivedMeta) {
|
|
31
|
-
throw new Error("global-feature-state-table: asEntityTableMeta(globalFeatureStateTable) returned undefined " +
|
|
32
|
-
"— the pgTable definition no longer round-trips through the unmanaged-table meta builder.");
|
|
33
|
-
}
|
|
34
|
-
export const globalFeatureStateTableMeta = derivedMeta;
|
|
29
|
+
export const globalFeatureStateTableMeta = requireEntityTableMeta(globalFeatureStateTable, "globalFeatureStateTable");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
|
|
1
2
|
import type { ToggleSyncSignal } from "./toggle-runtime.js";
|
|
2
3
|
export type RedisToggleSyncSignal = ToggleSyncSignal & {
|
|
3
4
|
close(): Promise<void>;
|
|
4
5
|
};
|
|
5
|
-
export declare function createRedisToggleSyncSignal(redisUrl: string): RedisToggleSyncSignal;
|
|
6
|
+
export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions): RedisToggleSyncSignal;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRedisPubSubSignal } from "@cosmicdrift/kumiko-framework/redis";
|
|
1
|
+
import { createRedisPubSubSignal, } from "@cosmicdrift/kumiko-framework/redis";
|
|
2
2
|
// Single fixed channel — unlike the SSE broker there's no per-tenant/
|
|
3
3
|
// per-user variance to multiplex, every toggle flip is global.
|
|
4
4
|
const TOGGLE_SYNC_CHANNEL = "kumiko:feature-toggles:cache-sync";
|
|
@@ -15,11 +15,12 @@ function isTogglePayload(value) {
|
|
|
15
15
|
// replicas > 1 is expected to set REDIS_URL, so app-boot code should build
|
|
16
16
|
// this only when REDIS_URL is present and pass it into
|
|
17
17
|
// createFeatureToggleRuntime.
|
|
18
|
-
export function createRedisToggleSyncSignal(redisUrl) {
|
|
18
|
+
export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
|
|
19
19
|
const signal = createRedisPubSubSignal({
|
|
20
20
|
redisUrl,
|
|
21
21
|
channelPattern: TOGGLE_SYNC_CHANNEL,
|
|
22
22
|
label: "feature-toggles",
|
|
23
|
+
...(clientOptions ? { clientOptions } : {}),
|
|
23
24
|
});
|
|
24
25
|
return {
|
|
25
26
|
publish(featureName, enabled) {
|
|
@@ -36,7 +36,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
36
36
|
r.requires("secrets");
|
|
37
37
|
r.requires("file-foundation");
|
|
38
38
|
const secretAccessKey = r.secret("s3.secretAccessKey", {
|
|
39
|
-
label: { en: "S3
|
|
39
|
+
label: { en: "S3 secret access key" },
|
|
40
40
|
hint: {
|
|
41
41
|
en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
|
|
42
42
|
},
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { defineFeature, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
|
|
15
15
|
import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
|
|
16
|
+
const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON = "iterates every tenant and checks fileRef owners per tenant";
|
|
16
17
|
export function createFilesTenantDataFeature() {
|
|
17
18
|
return defineFeature("files-tenant-data", (r) => {
|
|
18
19
|
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`.");
|
|
@@ -31,9 +32,9 @@ export function createFilesTenantDataFeature() {
|
|
|
31
32
|
concurrency: "skip",
|
|
32
33
|
schema: sweepOrphanedDerivativesPayloadSchema,
|
|
33
34
|
escapeHatch: {
|
|
34
|
-
reason:
|
|
35
|
+
reason: SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON,
|
|
35
36
|
},
|
|
36
|
-
handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(
|
|
37
|
+
handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON)),
|
|
37
38
|
});
|
|
38
39
|
});
|
|
39
40
|
}
|