@cosmicdrift/kumiko-bundled-features 0.334.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/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/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/handlers/switch-plan.write.js +5 -8
- package/dist/cap-counter/book-cap-usage.js +15 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- 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/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/web/i18n.js +1 -0
- 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/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- 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/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/files-tenant-data/index.js +3 -2
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- 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/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/tenant-job-failure-table.js +2 -6
- 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/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -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.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/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.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/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- 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 +8 -5
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +2 -5
- 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/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- 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/tier-engine/feature.js +3 -2
- 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-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 +2 -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
|
@@ -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
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { joinRowParentIsVisible } from "../../shared/index.js";
|
|
1
|
+
import { denyUnlessJoinRowParentVisible } from "../../shared/index.js";
|
|
3
2
|
import { folderAssignmentAggregateId } from "../aggregate-id.js";
|
|
4
3
|
import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
|
|
5
4
|
import { folderAssignmentExecutor } from "../executor.js";
|
|
@@ -19,15 +18,9 @@ export function createClearFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
|
|
|
19
18
|
description: "Unfiles a host entity so it sits in no folder at all, reporting success when it was already unfiled; use it to take an entity out of its folder without touching the folder itself.",
|
|
20
19
|
handler: async (event, ctx) => {
|
|
21
20
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// Checked FIRST, ahead of every lookup below, so a denied caller can't tell
|
|
26
|
-
// an invisible parent apart from a missing assignment or an unknown folder —
|
|
27
|
-
// every path answers with the same NotFoundError.
|
|
28
|
-
if (!(await joinRowParentIsVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db))) {
|
|
29
|
-
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
30
|
-
}
|
|
21
|
+
const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db);
|
|
22
|
+
if (denied)
|
|
23
|
+
return denied;
|
|
31
24
|
const id = folderAssignmentAggregateId(event.user.tenantId, payload.entityType, payload.entityId);
|
|
32
25
|
const existing = await folderAssignmentExecutor.detail({ id }, event.user, ctx.db);
|
|
33
26
|
if (!existing) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
-
import {
|
|
2
|
+
import { denyUnlessJoinRowParentVisible } from "../../shared/index.js";
|
|
3
3
|
import { folderAssignmentAggregateId } from "../aggregate-id.js";
|
|
4
4
|
import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
|
|
5
5
|
import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
|
|
@@ -35,15 +35,9 @@ export function createSetFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
|
|
|
35
35
|
description: "Files a host entity into one existing folder and moves it out of whatever folder it was in, since an entity belongs to at most one folder; use it for both the first filing and every later move.",
|
|
36
36
|
handler: async (event, ctx) => {
|
|
37
37
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Checked FIRST, ahead of every lookup below, so a denied caller can't tell
|
|
42
|
-
// an invisible parent apart from a missing assignment or an unknown folder —
|
|
43
|
-
// every path answers with the same NotFoundError.
|
|
44
|
-
if (!(await joinRowParentIsVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db))) {
|
|
45
|
-
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
46
|
-
}
|
|
38
|
+
const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db);
|
|
39
|
+
if (denied)
|
|
40
|
+
return denied;
|
|
47
41
|
const id = folderAssignmentAggregateId(event.user.tenantId, payload.entityType, payload.entityId);
|
|
48
42
|
const folder = await folderExecutor.detail({ id: payload.folderId }, event.user, ctx.db);
|
|
49
43
|
if (!folder)
|
|
@@ -12,6 +12,7 @@ import { discardDraftWrite } from "./handlers/discard.write.js";
|
|
|
12
12
|
import { getDraftQuery } from "./handlers/get.query.js";
|
|
13
13
|
import { listDraftsQuery } from "./handlers/list.query.js";
|
|
14
14
|
import { saveDraftWrite } from "./handlers/save.write.js";
|
|
15
|
+
const CLEANUP_ESCAPE_HATCH_REASON = "purges stale drafts of every tenant";
|
|
15
16
|
function registerFormDraft(r) {
|
|
16
17
|
r.describe("Per-user, per-tenant working copy of an in-progress form, saved BEFORE the real domain entity exists. Owns one event-sourced entity, `form-draft` (`read_form_drafts`), keyed by a caller-assigned draftKey (typically screenId + optional hostEntityId), unique per (tenant, owner, draftKey). `save` upserts the draft blob ({ values, stepIndex, savedAt } — savedAt stamped server-side), `discard` deletes it (called once the real submit succeeds), `get` resumes it, `list` finds a user's open drafts for a given screenId (draftKey prefix match) — the fallback when a client-generated draftId is lost. Ownership is enforced by a per-row owner filter in every handler, not by roles — a foreign user's save/discard/get/list for someone else's draftKey never sees or touches that row. Never holds anything that already lives in a domain stream; the consuming app is responsible for discarding once the domain write succeeds. A daily cron job hard-deletes drafts past a configurable retention window (`form-draft:config:retention-days`, SystemAdmin-writable, default 30 days).");
|
|
17
18
|
r.uiHints({
|
|
@@ -36,8 +37,8 @@ function registerFormDraft(r) {
|
|
|
36
37
|
name: "cleanup",
|
|
37
38
|
trigger: { cron: "0 3 * * *" },
|
|
38
39
|
concurrency: "skip",
|
|
39
|
-
escapeHatch: { reason:
|
|
40
|
-
handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(
|
|
40
|
+
escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
|
|
41
|
+
handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
|
|
41
42
|
});
|
|
42
43
|
}
|
|
43
44
|
export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
|
|
@@ -73,18 +73,17 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
73
73
|
const keys = ownedRefs.map((ref) => ref.storageKey);
|
|
74
74
|
const log = ctx.log;
|
|
75
75
|
const schedule = ctx.scheduleAfterCommit;
|
|
76
|
+
const release = async () => {
|
|
77
|
+
await releaseDraftFileRefs(keys, async (key) => {
|
|
78
|
+
await files.ref(key).delete();
|
|
79
|
+
}, log);
|
|
80
|
+
};
|
|
76
81
|
if (schedule) {
|
|
77
|
-
schedule(
|
|
78
|
-
await releaseDraftFileRefs(keys, async (key) => {
|
|
79
|
-
await files.ref(key).delete();
|
|
80
|
-
}, log);
|
|
81
|
-
});
|
|
82
|
+
schedule(release);
|
|
82
83
|
}
|
|
83
84
|
else {
|
|
84
85
|
// Unit/harness paths without a commit sink — best-effort inline.
|
|
85
|
-
await
|
|
86
|
-
await files.ref(key).delete();
|
|
87
|
-
}, log);
|
|
86
|
+
await release();
|
|
88
87
|
}
|
|
89
88
|
}
|
|
90
89
|
return { isSuccess: true, data: { discarded: true } };
|
|
@@ -16,6 +16,12 @@ export const getDraftQuery = defineQueryHandler({
|
|
|
16
16
|
if (!row)
|
|
17
17
|
return { draft: null };
|
|
18
18
|
const parsed = formDraftBlobSchema.safeParse(row.draft);
|
|
19
|
-
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
// Schema evolution or a corrupt jsonb row: surface it to operators
|
|
21
|
+
// instead of silently making the user's draft vanish.
|
|
22
|
+
ctx.log?.warn?.(`[form-draft:get] stored blob for draftKey=${query.payload.draftKey} failed schema, treating as absent: ${parsed.error.message}`);
|
|
23
|
+
return { draft: null };
|
|
24
|
+
}
|
|
25
|
+
return { draft: parsed.data };
|
|
20
26
|
},
|
|
21
27
|
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Parses a comma-separated operator host allowlist env var. Never throws —
|
|
2
|
+
* an unset or empty value just means no bypass. */
|
|
3
|
+
export declare function readHostAllowlistFromEnv(envVar: string, env?: Readonly<Record<string, string | undefined>>): readonly string[];
|
|
4
|
+
export declare function isHostAllowlisted(host: string, allowlist: readonly string[]): boolean;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Parses a comma-separated operator host allowlist env var. Never throws —
|
|
2
|
+
* an unset or empty value just means no bypass. */
|
|
3
|
+
export function readHostAllowlistFromEnv(envVar, env = process.env) {
|
|
4
|
+
const raw = env[envVar];
|
|
5
|
+
if (!raw)
|
|
6
|
+
return [];
|
|
7
|
+
return raw
|
|
8
|
+
.split(",")
|
|
9
|
+
.map((host) => host.trim())
|
|
10
|
+
.filter((host) => host.length > 0);
|
|
11
|
+
}
|
|
12
|
+
export function isHostAllowlisted(host, allowlist) {
|
|
13
|
+
return allowlist.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
|
|
14
|
+
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
|
|
2
|
+
export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
2
3
|
export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Public API of foundation-shared — utilities consumed by the per-tenant
|
|
2
2
|
// Foundation packages (ai-foundation, mail-foundation, file-foundation).
|
|
3
3
|
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
|
|
4
|
+
export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
4
5
|
export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { lookup } from "node:dns/promises";
|
|
2
2
|
export declare const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
|
|
3
|
-
/** Parses the comma-separated operator allowlist env var. Never throws —
|
|
4
|
-
* an unset or empty value just means no bypass. */
|
|
5
3
|
export declare function readAllowedPrivateMailHostsFromEnv(env?: Readonly<Record<string, string | undefined>>): readonly string[];
|
|
6
4
|
export type MailHostGuardOptions = {
|
|
7
5
|
readonly allowedPrivateMailHosts?: readonly string[];
|
|
@@ -16,23 +16,13 @@
|
|
|
16
16
|
// conflict check tripping when both features are mounted together.
|
|
17
17
|
import { isIP } from "node:net";
|
|
18
18
|
import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
|
|
19
|
+
import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
19
20
|
export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
|
|
20
|
-
/** Parses the comma-separated operator allowlist env var. Never throws —
|
|
21
|
-
* an unset or empty value just means no bypass. */
|
|
22
21
|
export function readAllowedPrivateMailHostsFromEnv(env = process.env) {
|
|
23
|
-
|
|
24
|
-
if (!raw)
|
|
25
|
-
return [];
|
|
26
|
-
return raw
|
|
27
|
-
.split(",")
|
|
28
|
-
.map((host) => host.trim())
|
|
29
|
-
.filter((host) => host.length > 0);
|
|
30
|
-
}
|
|
31
|
-
function isAllowedPrivateMailHost(host, allowed) {
|
|
32
|
-
return allowed.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
|
|
22
|
+
return readHostAllowlistFromEnv(MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, env);
|
|
33
23
|
}
|
|
34
24
|
export async function resolveMailConnectTarget(host, options = {}) {
|
|
35
|
-
if (
|
|
25
|
+
if (isHostAllowlisted(host, options.allowedPrivateMailHosts ?? [])) {
|
|
36
26
|
return { host };
|
|
37
27
|
}
|
|
38
28
|
const resolved = await resolvePublicHostname(host, options.lookupFn);
|
|
@@ -73,6 +73,12 @@ export function createInboundMailConnectRoutes(options) {
|
|
|
73
73
|
verify: async (request) => verifyCallbackRequest(request, options.stateSecret),
|
|
74
74
|
handler: async (c, verified, deps) => {
|
|
75
75
|
const { code, state } = verified;
|
|
76
|
+
// Checked before exchangeCode/connectAccount: the code is single-use and
|
|
77
|
+
// an account created without a persistable refresh token is an orphan.
|
|
78
|
+
// `deps.secrets` is optional on SignatureExtraRouteDeps.
|
|
79
|
+
if (!deps.secrets) {
|
|
80
|
+
return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
|
|
81
|
+
}
|
|
76
82
|
const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
|
|
77
83
|
let plugin;
|
|
78
84
|
try {
|
|
@@ -123,11 +129,7 @@ export function createInboundMailConnectRoutes(options) {
|
|
|
123
129
|
}
|
|
124
130
|
// Refresh-token into the per-account secret slot (Slot = accountId).
|
|
125
131
|
// Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
|
|
126
|
-
// sync path.
|
|
127
|
-
// without it the token would be silently lost, so fail loud instead.
|
|
128
|
-
if (!deps.secrets) {
|
|
129
|
-
return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
|
|
130
|
-
}
|
|
132
|
+
// sync path.
|
|
131
133
|
await deps.secrets.set(state.tenantId, inboundCredentialSecretKey(accountId), tokens.refreshToken, {
|
|
132
134
|
redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
|
|
133
135
|
hint: `OAuth refresh token for inbound mail account ${accountId}`,
|
|
@@ -121,7 +121,8 @@ export function createInboundMailSupervisor(deps) {
|
|
|
121
121
|
return null; // korrupter Cursor = wie kein Cursor → Backfill, Dedup fängt Dubletten
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
async function saveCursor(
|
|
124
|
+
async function saveCursor(account, cursor) {
|
|
125
|
+
const accountId = account.id;
|
|
125
126
|
const now = Temporal.Now.instant().toString();
|
|
126
127
|
const serialized = JSON.stringify(cursor);
|
|
127
128
|
const existing = await fetchOne(deps.db, syncCursorTable, {
|
|
@@ -135,6 +136,8 @@ export function createInboundMailSupervisor(deps) {
|
|
|
135
136
|
}
|
|
136
137
|
await insertOne(deps.db, syncCursorTable, {
|
|
137
138
|
id: crypto.randomUUID(),
|
|
139
|
+
// Direct-write store: no executor stamps the NOT NULL tenant_id for us.
|
|
140
|
+
tenantId: account.tenantId,
|
|
138
141
|
accountId,
|
|
139
142
|
scope: CURSOR_SCOPE,
|
|
140
143
|
cursor: serialized,
|
|
@@ -227,7 +230,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
227
230
|
maxMessages: budget,
|
|
228
231
|
});
|
|
229
232
|
await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
|
|
230
|
-
await saveCursor(account
|
|
233
|
+
await saveCursor(account, result.nextCursor);
|
|
231
234
|
cursor = result.nextCursor;
|
|
232
235
|
budget -= result.messages.length;
|
|
233
236
|
if (!result.hasMore || budget <= 0)
|
|
@@ -267,51 +270,52 @@ export function createInboundMailSupervisor(deps) {
|
|
|
267
270
|
// lifecycle — it keeps renewing across reconnect backoff, not just while
|
|
268
271
|
// `plugin.watch()` is actually connected, so a flaky IMAP link doesn't
|
|
269
272
|
// make this worker lose the account to a peer mid-backoff.
|
|
270
|
-
// kumiko-lint-ignore complexity-budget watch lease renew backoff/re-acquire loop
|
|
271
273
|
function scheduleRenew(account, state, generation) {
|
|
272
274
|
state.renewTimer = setTimeout(() => {
|
|
273
|
-
|
|
274
|
-
void (async () => {
|
|
275
|
-
state.renewTimer = null;
|
|
276
|
-
// skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
|
|
277
|
-
if (!running || state.generation !== generation || !state.lockToken || !deps.lock)
|
|
278
|
-
return;
|
|
279
|
-
let renewed;
|
|
280
|
-
try {
|
|
281
|
-
renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
|
|
282
|
-
}
|
|
283
|
-
catch (err) {
|
|
284
|
-
log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
285
|
-
state.renewFailures += 1;
|
|
286
|
-
// After failures spanning a full lease TTL, tear down — otherwise a
|
|
287
|
-
// peer can acquire the expired key while we still hold an IDLE conn.
|
|
288
|
-
if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
|
|
289
|
-
log(`inbound-mail: watch lease renew for account ${account.id} failed across TTL — tearing down local watcher`);
|
|
290
|
-
state.lockToken = null;
|
|
291
|
-
await stopWatcher(account.id);
|
|
292
|
-
// skip: local watcher torn down after renew failures spanning a full lease TTL.
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
if (running && state.generation === generation && state.lockToken) {
|
|
296
|
-
scheduleRenew(account, state, generation);
|
|
297
|
-
}
|
|
298
|
-
// skip: renew already rescheduled above (or conditions no longer hold) — nothing left to do this tick.
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
state.renewFailures = 0;
|
|
302
|
-
if (!renewed) {
|
|
303
|
-
log(`inbound-mail: watch lease for account ${account.id} lost — tearing down local watcher`);
|
|
304
|
-
state.lockToken = null;
|
|
305
|
-
await stopWatcher(account.id);
|
|
306
|
-
// skip: watcher already torn down by stopWatcher() above — nothing left to do.
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
if (running && state.generation === generation && state.lockToken) {
|
|
310
|
-
scheduleRenew(account, state, generation);
|
|
311
|
-
}
|
|
312
|
-
})();
|
|
275
|
+
void renewLeaseOnce(account, state, generation);
|
|
313
276
|
}, renewIntervalMs);
|
|
314
277
|
}
|
|
278
|
+
async function tearDownAfterRenewFailures(account, state, reason) {
|
|
279
|
+
log(`inbound-mail: watch lease ${reason} for account ${account.id} — tearing down local watcher`);
|
|
280
|
+
state.lockToken = null;
|
|
281
|
+
await stopWatcher(account.id);
|
|
282
|
+
}
|
|
283
|
+
async function renewLeaseOnce(account, state, generation) {
|
|
284
|
+
state.renewTimer = null;
|
|
285
|
+
// skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
|
|
286
|
+
if (!running || state.generation !== generation || !state.lockToken || !deps.lock)
|
|
287
|
+
return;
|
|
288
|
+
let renewed;
|
|
289
|
+
try {
|
|
290
|
+
renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
294
|
+
state.renewFailures += 1;
|
|
295
|
+
// After failures spanning a full lease TTL, tear down — otherwise a
|
|
296
|
+
// peer can acquire the expired key while we still hold an IDLE conn.
|
|
297
|
+
if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
|
|
298
|
+
await tearDownAfterRenewFailures(account, state, "renew failed across TTL");
|
|
299
|
+
// skip: connection torn down, no reschedule
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
rescheduleIfStillHeld(account, state, generation);
|
|
303
|
+
// skip: renew failed but TTL not yet exhausted, retry already scheduled
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
state.renewFailures = 0;
|
|
307
|
+
if (!renewed) {
|
|
308
|
+
await tearDownAfterRenewFailures(account, state, "lost");
|
|
309
|
+
// skip: lease lost and torn down, nothing to reschedule
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
rescheduleIfStillHeld(account, state, generation);
|
|
313
|
+
}
|
|
314
|
+
function rescheduleIfStillHeld(account, state, generation) {
|
|
315
|
+
if (running && state.generation === generation && state.lockToken) {
|
|
316
|
+
scheduleRenew(account, state, generation);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
315
319
|
// kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional (#2460)
|
|
316
320
|
async function acquireWatchLease(account, state) {
|
|
317
321
|
if (!deps.lock || state.lockToken)
|
|
@@ -8,9 +8,14 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
|
|
|
8
8
|
running: "running";
|
|
9
9
|
}>>;
|
|
10
10
|
filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
11
|
-
field: z.
|
|
11
|
+
field: z.ZodLiteral<"status">;
|
|
12
12
|
op: z.ZodLiteral<"in">;
|
|
13
|
-
value: z.ZodArray<z.
|
|
13
|
+
value: z.ZodArray<z.ZodEnum<{
|
|
14
|
+
completed: "completed";
|
|
15
|
+
failed: "failed";
|
|
16
|
+
queued: "queued";
|
|
17
|
+
running: "running";
|
|
18
|
+
}>>;
|
|
14
19
|
}, z.core.$strip>>>;
|
|
15
20
|
sort: z.ZodOptional<z.ZodEnum<{
|
|
16
21
|
duration: "duration";
|
|
@@ -5,6 +5,7 @@ import * as z from "zod";
|
|
|
5
5
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
|
|
6
6
|
import { jobRunsTable } from "../job-run-table.js";
|
|
7
7
|
const KMS_POOL_CONCURRENCY = 4;
|
|
8
|
+
const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
|
|
8
9
|
async function decryptRunRow(row) {
|
|
9
10
|
let result = row;
|
|
10
11
|
if (typeof result["payload"] === "string") {
|
|
@@ -23,9 +24,13 @@ export const listQuery = defineQueryHandler({
|
|
|
23
24
|
description: "Lists job runs across all tenants newest-first, optionally filtered by job name and status; use it to check whether a job ran and whether it succeeded.",
|
|
24
25
|
schema: z.object({
|
|
25
26
|
jobName: z.string().optional(),
|
|
26
|
-
status:
|
|
27
|
+
status: jobRunStatusSchema.optional(),
|
|
27
28
|
filters: z
|
|
28
|
-
.array(z.object({
|
|
29
|
+
.array(z.object({
|
|
30
|
+
field: z.literal("status"),
|
|
31
|
+
op: z.literal("in"),
|
|
32
|
+
value: z.array(jobRunStatusSchema),
|
|
33
|
+
}))
|
|
29
34
|
.optional(),
|
|
30
35
|
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
31
36
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
@@ -47,6 +52,8 @@ export const listQuery = defineQueryHandler({
|
|
|
47
52
|
const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
|
|
48
53
|
if (statuses.length === 1)
|
|
49
54
|
where["status"] = statuses[0];
|
|
55
|
+
else if (statuses.length > 1)
|
|
56
|
+
where["status"] = { in: statuses };
|
|
50
57
|
const sortColumn = query.payload.sort ?? "startedAt";
|
|
51
58
|
const rows = await selectMany(db, jobRunsTable, where, {
|
|
52
59
|
orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
import { isPiiCiphertext, PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { InternalError, NotFoundError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
@@ -44,7 +44,8 @@ export const retryWrite = defineWriteHandler({
|
|
|
44
44
|
let decryptedPayloadJson = null;
|
|
45
45
|
if (run.payload) {
|
|
46
46
|
const decryptedPayload = await decryptStoredPii(run.payload, "payload", `job run ${event.payload.runId} payload`);
|
|
47
|
-
|
|
47
|
+
// Still-ciphertext means no KMS could decrypt it (rollout mode); not JSON either.
|
|
48
|
+
if (decryptedPayload === PII_ERASED_SENTINEL || isPiiCiphertext(decryptedPayload)) {
|
|
48
49
|
return writeFailure(new UnprocessableError(JobErrors.payloadErased, {
|
|
49
50
|
i18nKey: "jobs.errors.payloadErased",
|
|
50
51
|
}));
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
|
|
4
5
|
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
6
|
import * as z from "zod";
|
|
6
7
|
import { tenantJobFailuresTable } from "../tenant-job-failure-table.js";
|
|
7
8
|
const DEFAULT_LIMIT = 50;
|
|
8
|
-
// Mirrors job-runner.ts's jobSubjectKey: sorted field names, so a caller that
|
|
9
|
-
// passes the same subject values gets the same string the writer stored.
|
|
10
|
-
function subjectKey(subject) {
|
|
11
|
-
return JSON.stringify(Object.keys(subject)
|
|
12
|
-
.sort()
|
|
13
|
-
.map((field) => [field, subject[field] ?? null]));
|
|
14
|
-
}
|
|
15
9
|
function isSubjectEntry(value) {
|
|
16
10
|
return Array.isArray(value) && typeof value[0] === "string";
|
|
17
11
|
}
|
|
@@ -28,7 +22,7 @@ function parseSubject(stored) {
|
|
|
28
22
|
}
|
|
29
23
|
export const tenantFailuresQuery = defineQueryHandler({
|
|
30
24
|
name: "failures",
|
|
31
|
-
description: "Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
|
|
25
|
+
description: "Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
|
|
32
26
|
schema: z.object({
|
|
33
27
|
jobName: z.string().optional(),
|
|
34
28
|
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
@@ -52,7 +46,7 @@ export const tenantFailuresQuery = defineQueryHandler({
|
|
|
52
46
|
if (query.payload.jobName)
|
|
53
47
|
where["jobName"] = query.payload.jobName;
|
|
54
48
|
if (query.payload.subject)
|
|
55
|
-
where["subject"] =
|
|
49
|
+
where["subject"] = serializeJobSubject(query.payload.subject);
|
|
56
50
|
const rows = await selectMany(db, tenantJobFailuresTable, where, {
|
|
57
51
|
orderBy: { col: "failedAt", direction: "desc" },
|
|
58
52
|
limit: query.payload.limit ?? DEFAULT_LIMIT,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { deleteMany, fetchOne, insertMany, insertOne, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { configuredPiiSubjectKms, encryptPiiValueForSubject, KeyErasedError, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
5
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
6
|
import { mapWithConcurrency } from "../shared/index.js";
|
|
6
7
|
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.js";
|
|
@@ -128,6 +129,21 @@ async function clearTenantJobFailure(db, jobName, outcome) {
|
|
|
128
129
|
return;
|
|
129
130
|
await deleteMany(db, tenantJobFailuresTable, where);
|
|
130
131
|
}
|
|
132
|
+
const log = createFallbackLogger("job-run-logger");
|
|
133
|
+
// The tenant failure record is an optional side write: a throw here must not
|
|
134
|
+
// skip the run-row update or turn a completed run into a BullMQ retry.
|
|
135
|
+
async function isolateTenantFailureWrite(write, jobName, bullJobId) {
|
|
136
|
+
try {
|
|
137
|
+
await write();
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
log.error("tenant job failure record write failed", {
|
|
141
|
+
jobName,
|
|
142
|
+
bullJobId,
|
|
143
|
+
error: e instanceof Error ? e.message : String(e),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
131
147
|
export function createJobRunLogger(opts) {
|
|
132
148
|
const { db } = opts;
|
|
133
149
|
const runIdByBullJobId = new Map();
|
|
@@ -210,7 +226,7 @@ export function createJobRunLogger(opts) {
|
|
|
210
226
|
// Before the run-row write and independent of it: a successful run
|
|
211
227
|
// clears the tenant's failure record even when the run row itself is
|
|
212
228
|
// unreachable (the state-loss return below).
|
|
213
|
-
await clearTenantJobFailure(db, jobName, outcome);
|
|
229
|
+
await isolateTenantFailureWrite(() => clearTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
|
|
214
230
|
const resolved = await resolveRun(bullJobId);
|
|
215
231
|
// skip: state loss between start + complete (worker restart, cache
|
|
216
232
|
// evicted AND DB has no matching bull_job_id). Rare edge case; we
|
|
@@ -255,7 +271,7 @@ export function createJobRunLogger(opts) {
|
|
|
255
271
|
onJobFailed: async (jobName, bullJobId, error, logs, outcome) => {
|
|
256
272
|
// Mirror of onJobComplete: recorded independently of the run row, so a
|
|
257
273
|
// tenant still learns their job failed if the row is unreachable.
|
|
258
|
-
await recordTenantJobFailure(db, jobName, outcome);
|
|
274
|
+
await isolateTenantFailureWrite(() => recordTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
|
|
259
275
|
const resolved = await resolveRun(bullJobId);
|
|
260
276
|
// skip: same rare state-loss case as in onJobComplete — drop the
|
|
261
277
|
// failure write rather than forge a run row from scratch.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
// The tenant's own view of a failed job (fw#3079). Direct-write store like
|
|
4
4
|
// store_job_runs: job-run-logger.ts writes it from the BullMQ callbacks,
|
|
@@ -22,8 +22,4 @@ export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
|
|
|
22
22
|
messageKey: text("message_key").notNull(),
|
|
23
23
|
failedAt: instant("failed_at").default(sql `now()`).notNull(),
|
|
24
24
|
});
|
|
25
|
-
const
|
|
26
|
-
if (!derivedTenantJobFailuresTableMeta) {
|
|
27
|
-
throw new Error("tenantJobFailuresTable: table carries no EntityTableMeta — built via table()?");
|
|
28
|
-
}
|
|
29
|
-
export const tenantJobFailuresTableMeta = derivedTenantJobFailuresTableMeta;
|
|
25
|
+
export const tenantJobFailuresTableMeta = requireEntityTableMeta(tenantJobFailuresTable, "tenantJobFailuresTable");
|
|
@@ -4,6 +4,7 @@ import { DEFAULT_LEDGER_ACCESS } from "../constants.js";
|
|
|
4
4
|
import { accountExecutor, scheduleExecutor, transactionExecutor, transactionTable, } from "../executor.js";
|
|
5
5
|
import { findReversedIds, isoMonth, scheduleReference } from "../recurring.js";
|
|
6
6
|
import { confirmSchedulePeriodPayloadSchema, } from "../schemas.js";
|
|
7
|
+
import { readSubjectReference } from "../subject-reference.js";
|
|
7
8
|
// confirm-schedule-period — turn ONE projected period of a schedule into a posted,
|
|
8
9
|
// balanced transaction (debit +amount / credit −amount), tagged with
|
|
9
10
|
// scheduleReference(scheduleId, period) so the host can merge Soll vs. Ist.
|
|
@@ -70,8 +71,7 @@ export function createConfirmSchedulePeriodHandler(access = DEFAULT_LEDGER_ACCES
|
|
|
70
71
|
const amount = payload.amount ?? Number(schedule["amount"]);
|
|
71
72
|
// The subject is a property of the schedule, not the confirm call — a
|
|
72
73
|
// period without one stays without one, no backfill/error.
|
|
73
|
-
const
|
|
74
|
-
const subjectId = typeof schedule["subjectId"] === "string" ? schedule["subjectId"] : null;
|
|
74
|
+
const { subjectType, subjectId } = readSubjectReference(schedule);
|
|
75
75
|
const created = await transactionExecutor.create({
|
|
76
76
|
id: generateId(),
|
|
77
77
|
date: payload.date ?? `${payload.period}-01`,
|
|
@@ -4,6 +4,7 @@ import { DEFAULT_LEDGER_ACCESS } from "../constants.js";
|
|
|
4
4
|
import { transactionExecutor, transactionTable } from "../executor.js";
|
|
5
5
|
import { normalizeLines } from "../reports.js";
|
|
6
6
|
import { reverseTransactionPayloadSchema } from "../schemas.js";
|
|
7
|
+
import { readSubjectReference } from "../subject-reference.js";
|
|
7
8
|
// reverse-transaction (Storno) — the ONLY correction path for a posted entry.
|
|
8
9
|
// Books the mirror image (every amount negated) as a new posted entry that
|
|
9
10
|
// references the original, leaving the original untouched. This is why
|
|
@@ -51,8 +52,7 @@ export function createReverseTransactionHandler(access = DEFAULT_LEDGER_ACCESS)
|
|
|
51
52
|
}));
|
|
52
53
|
// The reversal shares its subject with the original so filtering by
|
|
53
54
|
// subjectId still finds the Storno; unset on the original stays unset.
|
|
54
|
-
const
|
|
55
|
-
const subjectId = typeof original["subjectId"] === "string" ? original["subjectId"] : null;
|
|
55
|
+
const { subjectType, subjectId } = readSubjectReference(original);
|
|
56
56
|
return transactionExecutor.create({
|
|
57
57
|
id: generateId(),
|
|
58
58
|
// Pass the original's date through untyped — create takes Record<string,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Subject columns are nullable; anything that is not a string reads as unset.
|
|
2
|
+
export function readSubjectReference(row) {
|
|
3
|
+
return {
|
|
4
|
+
subjectType: typeof row["subjectType"] === "string" ? row["subjectType"] : null,
|
|
5
|
+
subjectId: typeof row["subjectId"] === "string" ? row["subjectId"] : null,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
@@ -36,6 +36,12 @@ export function createLegalPagesFeature(opts = {}) {
|
|
|
36
36
|
const routes = opts.routes ?? LEGAL_ROUTES;
|
|
37
37
|
const requiredBlocks = opts.requiredBlocks ?? LEGAL_REQUIRED_BLOCKS;
|
|
38
38
|
validateRoutes(routes);
|
|
39
|
+
if (opts.routes !== undefined && opts.routes.length > 0 && opts.requiredBlocks === undefined) {
|
|
40
|
+
const coversDefaultBlock = LEGAL_REQUIRED_BLOCKS.some((block) => opts.routes?.some((route) => route.slug === block.slug && route.lang === block.lang));
|
|
41
|
+
if (!coversDefaultBlock) {
|
|
42
|
+
throw new Error("legal-pages: custom `routes` without `requiredBlocks` — pass `requiredBlocks` explicitly (or `[]`)");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
39
45
|
return defineFeature("legal-pages", (r) => {
|
|
40
46
|
r.describe("Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.");
|
|
41
47
|
r.uiHints({
|
|
@@ -75,9 +81,10 @@ export function createLegalPagesFeature(opts = {}) {
|
|
|
75
81
|
}
|
|
76
82
|
const etag = computeRevisionEtag([
|
|
77
83
|
SYSTEM_TENANT_ID,
|
|
84
|
+
route.path,
|
|
78
85
|
route.slug,
|
|
79
86
|
route.lang,
|
|
80
|
-
data.
|
|
87
|
+
String(data.modifiedAt ?? ""),
|
|
81
88
|
]);
|
|
82
89
|
const extra = { "content-type": "text/html; charset=utf-8" };
|
|
83
90
|
// 304 (Revision unverändert) und HEAD überspringen beide das
|
|
@@ -18,7 +18,7 @@ export function createBrandingQuery(opts) {
|
|
|
18
18
|
name: "branding",
|
|
19
19
|
schema: z.object({}),
|
|
20
20
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
21
|
-
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
21
|
+
rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
|
|
22
22
|
description: "Reads the tenant's public branding values for the rendered page shell, adding the raw custom CSS only when the app opted in and the per-tenant CSS toggle is on; anonymous callers may read it because it dresses public pages.",
|
|
23
23
|
handler: async (_query, ctx) => {
|
|
24
24
|
const base = await readBranding(ctx.config);
|