@cosmicdrift/kumiko-bundled-features 0.365.0 → 0.366.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/feature.js +9 -9
- package/dist/agent-tools/tool-dispatch.d.ts +9 -1
- package/dist/agent-tools/tool-dispatch.js +55 -5
- package/dist/audit/feature.js +1 -1
- package/dist/auth-email-password/constants.d.ts +0 -1
- package/dist/auth-email-password/constants.js +0 -2
- package/dist/auth-email-password/feature.js +3 -3
- package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
- package/dist/auth-mfa/feature.js +4 -4
- package/dist/auth-mfa-user-data/index.js +1 -1
- package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
- package/dist/billing-foundation/feature.js +1 -1
- package/dist/cap-counter/cap-reservation.js +73 -25
- package/dist/cap-counter/constants.d.ts +1 -0
- package/dist/cap-counter/constants.js +2 -0
- package/dist/cap-counter/feature.js +2 -2
- package/dist/cap-counter/tables.js +5 -2
- package/dist/cap-overview/feature.js +3 -3
- package/dist/channel-discord/feature.js +2 -2
- package/dist/channel-email/feature.js +1 -1
- package/dist/channel-in-app/feature.js +1 -1
- package/dist/channel-push/feature.js +1 -1
- package/dist/channel-slack/feature.js +2 -2
- package/dist/channel-teams/feature.js +2 -2
- package/dist/channel-telegram/feature.js +2 -2
- package/dist/compliance-profiles/feature.js +1 -1
- package/dist/compliance-profiles-ops/index.js +3 -3
- package/dist/config/feature.js +1 -1
- package/dist/custom-fields/feature.js +2 -2
- package/dist/custom-fields/wire-for-entity.js +17 -13
- package/dist/data-retention/feature.js +1 -1
- package/dist/data-retention/presets.d.ts +4 -4
- package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
- package/dist/delivery/db/queries/address-opt-outs.js +6 -2
- package/dist/delivery/db/queries/preferences.d.ts +1 -1
- package/dist/delivery/db/queries/preferences.js +4 -4
- package/dist/delivery/delivery-service.js +9 -2
- package/dist/delivery/feature.js +9 -5
- package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
- package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
- package/dist/delivery/handlers/set-preference.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-address.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
- package/dist/delivery/notification-type-aliases.d.ts +3 -0
- package/dist/delivery/notification-type-aliases.js +11 -0
- package/dist/derivatives-sharp/feature.js +1 -1
- package/dist/document-ingest-foundation/feature.js +2 -2
- package/dist/file-derivatives/feature.js +3 -3
- package/dist/file-foundation/feature.js +1 -1
- package/dist/file-provider-inmemory/feature.js +2 -2
- package/dist/file-provider-s3/feature.js +3 -3
- package/dist/file-provider-s3-env/feature.js +2 -2
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/folders/feature.js +2 -2
- package/dist/folders-user-data/index.js +1 -1
- package/dist/form-draft/feature.js +2 -2
- package/dist/form-draft-user-data/index.js +1 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
- package/dist/inbound-mail-foundation/connect-routes.js +6 -1
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +17 -7
- package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
- package/dist/inbound-provider-imap/feature.js +2 -2
- package/dist/inbound-provider-inmemory/feature.js +1 -1
- package/dist/ledger/feature.js +5 -5
- package/dist/legal-pages/feature.js +1 -1
- package/dist/mail-foundation/feature.js +1 -1
- package/dist/mail-transport-inmemory/feature.js +2 -2
- package/dist/mail-transport-smtp/feature.js +3 -3
- package/dist/managed-pages/css-gate.js +1 -1
- package/dist/managed-pages/feature.js +2 -2
- package/dist/metrics/feature.js +2 -2
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history-user-data/index.js +1 -1
- package/dist/personal-access-tokens/feature.js +17 -13
- package/dist/readiness/feature.js +2 -2
- package/dist/renderer-foundation/feature.js +1 -1
- package/dist/renderer-pdf/feature.js +1 -1
- package/dist/secrets/feature.js +1 -1
- package/dist/seo/feature.js +1 -1
- package/dist/sessions/feature.js +20 -16
- package/dist/step-dispatcher/constants.d.ts +10 -0
- package/dist/step-dispatcher/constants.js +17 -0
- package/dist/step-dispatcher/deliver-dispatch-request.d.ts +14 -0
- package/dist/step-dispatcher/deliver-dispatch-request.js +163 -0
- package/dist/step-dispatcher/feature.js +48 -101
- package/dist/step-dispatcher/webhook-runner.d.ts +7 -4
- package/dist/step-dispatcher/webhook-runner.js +26 -11
- package/dist/subscription-mollie/feature.js +1 -1
- package/dist/subscription-stripe/feature.js +3 -3
- package/dist/tags/feature.js +2 -2
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver-user-data/index.js +1 -1
- package/dist/tenant/feature.js +4 -4
- package/dist/tenant/seeding.js +1 -1
- package/dist/tenant-lifecycle/feature.js +2 -3
- package/dist/tenant-lifecycle/stages.js +4 -1
- package/dist/tenant-settings/feature.js +1 -1
- package/dist/tier-engine/feature.js +86 -72
- package/dist/user/feature.js +1 -1
- package/dist/user-data-rights/feature.js +4 -4
- package/dist/user-data-rights-defaults/feature.js +2 -2
- package/dist/user-profile/feature.js +4 -4
- package/dist/waitlist/feature.js +2 -2
- package/package.json +10 -10
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +13 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/delivery/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +13 -0
- package/src/step-dispatcher/changes.json +8 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// app owner still wires the supervisor in bin/server.ts:
|
|
15
15
|
//
|
|
16
16
|
// const supervisor = createInboundMailSupervisor({
|
|
17
|
-
// providerCtx: { registry: deps.registry, secrets },
|
|
17
|
+
// providerCtx: { registry: deps.registry, secrets, metricsFor: deps.metricsFor },
|
|
18
18
|
// db,
|
|
19
19
|
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
20
20
|
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
@@ -48,7 +48,7 @@ import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/ku
|
|
|
48
48
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
49
49
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
50
50
|
import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
|
|
51
|
-
import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities.js";
|
|
51
|
+
import { MAIL_ACCOUNT_PII_FIELDS, seenMessageTable, syncCursorTable } from "./entities.js";
|
|
52
52
|
import { createOAuthAccessTokenManager, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
|
|
53
53
|
import { mailAccountsProjectionTable } from "./projection.js";
|
|
54
54
|
import { resolveInboundProviderForAccount } from "./provider-factory.js";
|
|
@@ -255,6 +255,19 @@ export function createInboundMailSupervisor(deps) {
|
|
|
255
255
|
// ---------------------------------------------------------------
|
|
256
256
|
// Poll (Reconciliation).
|
|
257
257
|
// ---------------------------------------------------------------
|
|
258
|
+
function unknownMessageIdsFilterFor(account) {
|
|
259
|
+
return async (providerMessageIds) => {
|
|
260
|
+
if (providerMessageIds.length === 0)
|
|
261
|
+
return new Set();
|
|
262
|
+
const seen = await selectMany(deps.db, seenMessageTable, {
|
|
263
|
+
tenantId: account.tenantId,
|
|
264
|
+
accountId: account.id,
|
|
265
|
+
providerMessageId: { in: providerMessageIds },
|
|
266
|
+
}, { fields: ["providerMessageId"] });
|
|
267
|
+
const seenIds = new Set(seen.map((row) => row.providerMessageId));
|
|
268
|
+
return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
|
|
269
|
+
};
|
|
270
|
+
}
|
|
258
271
|
async function pollAccount(account, plugin) {
|
|
259
272
|
let cursor = await loadCursor(account.id);
|
|
260
273
|
let budget = maxMessagesPerPoll;
|
|
@@ -265,6 +278,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
265
278
|
const result = await plugin.fetch(deps.providerCtx, account, cursor, {
|
|
266
279
|
backfillWindowDays,
|
|
267
280
|
maxMessages: budget,
|
|
281
|
+
filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
|
|
268
282
|
...(accessToken !== undefined && { accessToken }),
|
|
269
283
|
});
|
|
270
284
|
await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
|
|
@@ -224,8 +224,8 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
224
224
|
category: "notifications",
|
|
225
225
|
recommended: false,
|
|
226
226
|
});
|
|
227
|
-
r.requires("inbound-mail-foundation");
|
|
228
|
-
r.requires("secrets");
|
|
227
|
+
r.requires({ features: ["inbound-mail-foundation"] });
|
|
228
|
+
r.requires({ features: ["secrets"] });
|
|
229
229
|
r.envSchema(mailAllowedPrivateHostsEnvSchema);
|
|
230
230
|
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
|
|
231
231
|
});
|
|
@@ -110,6 +110,6 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
|
|
|
110
110
|
category: "notifications",
|
|
111
111
|
recommended: false,
|
|
112
112
|
});
|
|
113
|
-
r.requires("inbound-mail-foundation");
|
|
113
|
+
r.requires({ features: ["inbound-mail-foundation"] });
|
|
114
114
|
r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
|
|
115
115
|
});
|
package/dist/ledger/feature.js
CHANGED
|
@@ -28,9 +28,9 @@ function registerLedger(r, access, toggleable) {
|
|
|
28
28
|
});
|
|
29
29
|
if (toggleable !== undefined)
|
|
30
30
|
r.toggleable(toggleable);
|
|
31
|
-
r.entity("account", accountEntity);
|
|
32
|
-
r.entity("transaction", transactionEntity);
|
|
33
|
-
r.entity("schedule", scheduleEntity);
|
|
31
|
+
r.entity({ name: "account", ...accountEntity });
|
|
32
|
+
r.entity({ name: "transaction", ...transactionEntity });
|
|
33
|
+
r.entity({ name: "schedule", ...scheduleEntity });
|
|
34
34
|
// Chart of accounts — plain CRUD, no custom logic. No delete in v1: removing an
|
|
35
35
|
// account that has postings would orphan them; a posting-aware guard lands with
|
|
36
36
|
// the postings projection (Phase 1).
|
|
@@ -73,8 +73,8 @@ function registerLedger(r, access, toggleable) {
|
|
|
73
73
|
access,
|
|
74
74
|
description: "Changes a recurring booking template's description, accounts, amount, interval or date window; use it when a standing order's terms change, already confirmed periods stay booked as they were.",
|
|
75
75
|
}));
|
|
76
|
-
r.hook("preSave", "schedule:create", requireScheduleSubjectPair);
|
|
77
|
-
r.hook("preSave", "schedule:update", requireScheduleSubjectPair);
|
|
76
|
+
r.hook({ type: "preSave", target: "schedule:create", handler: requireScheduleSubjectPair });
|
|
77
|
+
r.hook({ type: "preSave", target: "schedule:update", handler: requireScheduleSubjectPair });
|
|
78
78
|
r.writeHandler(createConfirmSchedulePeriodHandler(access));
|
|
79
79
|
r.queryHandler(defineEntityListHandler("schedule", scheduleEntity, {
|
|
80
80
|
access,
|
|
@@ -35,7 +35,7 @@ export function createLegalPagesFeature(opts = {}) {
|
|
|
35
35
|
category: "content",
|
|
36
36
|
recommended: false,
|
|
37
37
|
});
|
|
38
|
-
r.requires("template-resolver");
|
|
38
|
+
r.requires({ features: ["template-resolver"] });
|
|
39
39
|
// Public HTML routes (default: 4 DACH routes, configurable via opts.routes)
|
|
40
40
|
for (const route of routes) {
|
|
41
41
|
r.httpRoute({
|
|
@@ -52,7 +52,7 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
52
52
|
category: "notifications",
|
|
53
53
|
recommended: false,
|
|
54
54
|
});
|
|
55
|
-
r.requires("config");
|
|
55
|
+
r.requires({ features: ["config"] });
|
|
56
56
|
// Plugin extension-point. Provider-features register here. The
|
|
57
57
|
// entityName at registration time becomes the value tenants pick in
|
|
58
58
|
// `provider` config-key (e.g. "smtp", "brevo-api").
|
|
@@ -62,11 +62,11 @@ export function clearInbox(tenantId) {
|
|
|
62
62
|
// =============================================================================
|
|
63
63
|
export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
64
64
|
r.describe('Registers an in-process `"inmemory"` provider for `mail-foundation` that buffers sent mails per tenant instead of contacting an SMTP server. Use `getInbox(tenantId)` and `clearInbox(tenantId)` in demo apps and tests; not for production (buffer is process-memory, lost on restart).');
|
|
65
|
-
// Kein
|
|
65
|
+
// Kein requires auf "config" oder "secrets" — der
|
|
66
66
|
// In-Memory-Transport hat keine Config (nichts zu konfigurieren) und
|
|
67
67
|
// kein Secret. Der einzige hard-require ist mail-foundation, das den
|
|
68
68
|
// extension-point "mailTransport" definiert.
|
|
69
|
-
r.requires("mail-foundation");
|
|
69
|
+
r.requires({ features: ["mail-foundation"] });
|
|
70
70
|
const plugin = {
|
|
71
71
|
build: async (_ctx, tenantId) => {
|
|
72
72
|
// Returnt den per-tenant Buffer. Identitätsstabil zwischen calls
|
|
@@ -57,9 +57,9 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
57
57
|
category: "notifications",
|
|
58
58
|
recommended: false,
|
|
59
59
|
});
|
|
60
|
-
r.requires("config");
|
|
61
|
-
r.requires("secrets");
|
|
62
|
-
r.requires("mail-foundation");
|
|
60
|
+
r.requires({ features: ["config"] });
|
|
61
|
+
r.requires({ features: ["secrets"] });
|
|
62
|
+
r.requires({ features: ["mail-foundation"] });
|
|
63
63
|
r.envSchema(mailTransportSmtpEnvSchema);
|
|
64
64
|
// Provider-secret. Sensitive: redact-helper for admin-UI display.
|
|
65
65
|
const password = r.secret("smtp.password", {
|
|
@@ -15,7 +15,7 @@ import { MANAGED_PAGES_CSS_FEATURE } from "./branding.js";
|
|
|
15
15
|
export function createManagedPagesCssFeature() {
|
|
16
16
|
return defineFeature(MANAGED_PAGES_CSS_FEATURE, (r) => {
|
|
17
17
|
r.describe('Per-tenant toggle gate for the managed-pages custom-CSS (raw tenant CSS-inject) capability. Handler-less: composing it makes `managed-pages-css` a toggleable feature defaulting OFF, so an operator/tier can grant raw CSS-inject per tenant. managed-pages reads `ctx.hasFeature("managed-pages-css")` in its branding query and emits tenant CSS only when this toggle is on AND the app passed `allowCustomCss: true`. The tenant CSS is allowlist-sanitized + scoped at render regardless; this gate is the commercial/operator control, not the safety boundary.');
|
|
18
|
-
r.requires("managed-pages");
|
|
18
|
+
r.requires({ features: ["managed-pages"] });
|
|
19
19
|
r.toggleable({ default: false });
|
|
20
20
|
});
|
|
21
21
|
}
|
|
@@ -86,8 +86,8 @@ export function createManagedPagesFeature(opts) {
|
|
|
86
86
|
category: "content",
|
|
87
87
|
recommended: false,
|
|
88
88
|
});
|
|
89
|
-
r.requires("config");
|
|
90
|
-
r.entity("page", pageEntity);
|
|
89
|
+
r.requires({ features: ["config"] });
|
|
90
|
+
r.entity({ name: "page", ...pageEntity });
|
|
91
91
|
// Per-tenant branding config keys (scope: tenant). Write-validated via
|
|
92
92
|
// keyDef.pattern (hex / https) — see branding.ts. read:all so the
|
|
93
93
|
// anonymous render path may resolve them. The raw-CSS key is added only
|
package/dist/metrics/feature.js
CHANGED
|
@@ -103,7 +103,7 @@ export function createMetricsFeature(options) {
|
|
|
103
103
|
recommended: false,
|
|
104
104
|
});
|
|
105
105
|
for (const feature of requiredFeatures(metrics))
|
|
106
|
-
r.requires(feature);
|
|
106
|
+
r.requires({ features: [feature] });
|
|
107
107
|
for (const metric of metrics)
|
|
108
108
|
r.queryHandler(createTenantMetricQuery(metric, now));
|
|
109
109
|
r.translations({ keys: METRICS_I18N });
|
|
@@ -122,7 +122,7 @@ export function createSystemMetricsFeature(options) {
|
|
|
122
122
|
});
|
|
123
123
|
r.systemScope();
|
|
124
124
|
for (const feature of requiredFeatures(metrics))
|
|
125
|
-
r.requires(feature);
|
|
125
|
+
r.requires({ features: [feature] });
|
|
126
126
|
for (const metric of metrics)
|
|
127
127
|
r.queryHandler(createSystemMetricQuery(metric, now));
|
|
128
128
|
r.translations({ keys: METRICS_I18N });
|
|
@@ -27,13 +27,13 @@ function registerNotesHistory(r, access, ownership, parents) {
|
|
|
27
27
|
recommended: false,
|
|
28
28
|
});
|
|
29
29
|
const entity = createNoteEntryEntity(ownership, parents);
|
|
30
|
-
r.entity("note-entry", entity);
|
|
30
|
+
r.entity({ name: "note-entry", ...entity });
|
|
31
31
|
// No write/query handler of its own — populated only as a side effect of
|
|
32
32
|
// add-note (see handlers/add-note.write.ts), looked up by
|
|
33
33
|
// notes-history-user-data's forget cascade. Registering the entity (not
|
|
34
34
|
// just building it inline in the handler) is what makes it visible to the
|
|
35
35
|
// registry-wide GDPR boot guards and to executor.ts's table/projection setup.
|
|
36
|
-
r.entity("note-mention", noteMentionEntity);
|
|
36
|
+
r.entity({ name: "note-mention", ...noteMentionEntity });
|
|
37
37
|
r.writeHandler(createAddNoteHandler(access, createNoteEntryExecutor(ownership)));
|
|
38
38
|
r.queryHandler(defineEntityListHandler("note-entry", entity, {
|
|
39
39
|
access,
|
|
@@ -13,7 +13,7 @@ export const notesHistoryUserDataFeature = defineFeature("notes-history-user-dat
|
|
|
13
13
|
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
14
14
|
// would throw an "effectively disabled" boot warning even though the
|
|
15
15
|
// note-entry entity exists and the hooks work fine.
|
|
16
|
-
r.requires("user-data-rights");
|
|
16
|
+
r.requires({ features: ["user-data-rights"] });
|
|
17
17
|
r.optionalRequires("notes-history");
|
|
18
18
|
r.useExtension(EXT_USER_DATA, "note-entry", {
|
|
19
19
|
export: noteEntryExportHook,
|
|
@@ -52,7 +52,7 @@ export function createPersonalAccessTokensFeature(options) {
|
|
|
52
52
|
// roles — make both boot-time deps so a mis-wiring fails validateBoot.
|
|
53
53
|
// auth-foundation owns EXT_TOKEN_VERIFIER, which the useExtension below
|
|
54
54
|
// registers against.
|
|
55
|
-
r.requires("user", "tenant", "auth-foundation");
|
|
55
|
+
r.requires({ features: ["user", "tenant", "auth-foundation"] });
|
|
56
56
|
// Self-registers as a bearer-auth provider instead of the app wiring a
|
|
57
57
|
// dedicated patResolver callback — the middleware finds it generically
|
|
58
58
|
// via resolveTokenVerifier(), shape-matched by the PAT token prefix.
|
|
@@ -83,18 +83,22 @@ export function createPersonalAccessTokensFeature(options) {
|
|
|
83
83
|
// #497), so a postSave hook on "user" gets a poisoned ctx.db here. The
|
|
84
84
|
// concrete revoker is bound once run{Prod,Dev}App has a real db handle.
|
|
85
85
|
let autoRevokeOnPasswordChange = options.autoRevokeOnPasswordChange;
|
|
86
|
-
r.hook(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
r.hook({
|
|
87
|
+
type: "postSave",
|
|
88
|
+
target: { allOf: "user" },
|
|
89
|
+
handler: async (result) => {
|
|
90
|
+
// skip: nothing bound — same late-bind pattern as sessions/feature.ts
|
|
91
|
+
if (!autoRevokeOnPasswordChange)
|
|
92
|
+
return;
|
|
93
|
+
// skip: brand-new user, no PATs can exist yet
|
|
94
|
+
if (result.isNew)
|
|
95
|
+
return;
|
|
96
|
+
// skip: handler didn't touch any revoke-triggering field
|
|
97
|
+
if (!PAT_REVOKE_TRIGGERING_FIELDS.some((field) => result.changes[field] !== undefined)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await autoRevokeOnPasswordChange(String(result.id));
|
|
101
|
+
},
|
|
98
102
|
});
|
|
99
103
|
const bindAutoRevokeOnPasswordChange = (revoker) => {
|
|
100
104
|
// explicit constructor option wins over the runtime binding
|
|
@@ -16,8 +16,8 @@ export const readinessFeature = defineFeature("readiness", (r) => {
|
|
|
16
16
|
category: "operations",
|
|
17
17
|
recommended: false,
|
|
18
18
|
});
|
|
19
|
-
r.requires("config");
|
|
20
|
-
r.requires("secrets");
|
|
19
|
+
r.requires({ features: ["config"] });
|
|
20
|
+
r.requires({ features: ["secrets"] });
|
|
21
21
|
const queries = {
|
|
22
22
|
status: r.queryHandler(statusQuery),
|
|
23
23
|
};
|
|
@@ -17,7 +17,7 @@ export function createRendererFoundationFeature() {
|
|
|
17
17
|
category: "notifications",
|
|
18
18
|
recommended: false,
|
|
19
19
|
});
|
|
20
|
-
r.requires("template-resolver");
|
|
20
|
+
r.requires({ features: ["template-resolver"] });
|
|
21
21
|
r.extendsRegistrar(RENDERER_EXTENSION, {
|
|
22
22
|
onRegister: () => {
|
|
23
23
|
// Plugin-Konformitäts-Check könnte hier: shape-validation der
|
|
@@ -29,7 +29,7 @@ export function createRendererPdfFeature() {
|
|
|
29
29
|
category: "notifications",
|
|
30
30
|
recommended: false,
|
|
31
31
|
});
|
|
32
|
-
r.requires("renderer-foundation");
|
|
32
|
+
r.requires({ features: ["renderer-foundation"] });
|
|
33
33
|
r.useExtension(RENDERER_EXTENSION, "pdf", {
|
|
34
34
|
kinds: ["document-pdf"],
|
|
35
35
|
render: (req) => renderTablePdfRequest(req),
|
package/dist/secrets/feature.js
CHANGED
|
@@ -116,7 +116,7 @@ export function createSecretsFeature(opts = {}) {
|
|
|
116
116
|
// uniqueIndex are not expressible via the field-DSL (jsonb-without-default,
|
|
117
117
|
// now()-default), so the physical table is the DDL truth. Without this the
|
|
118
118
|
// generated migration omitted those columns → prod-500 (publicstatus#116).
|
|
119
|
-
r.entity("tenant-secret", tenantSecretEntity,
|
|
119
|
+
r.entity({ name: "tenant-secret", ...tenantSecretEntity, backingTable: tenantSecretsTable });
|
|
120
120
|
// Read-audit domain-event. Registered here so ops tools + MSPs can
|
|
121
121
|
// discover the type; secrets-context.get parses payloads against
|
|
122
122
|
// `secretReadSchema` at write time because the low-level append() path
|
package/dist/seo/feature.js
CHANGED
|
@@ -108,7 +108,7 @@ export function createSeoFeature(opts) {
|
|
|
108
108
|
// config keys need the config feature's write/read machinery to actually
|
|
109
109
|
// function (same hard dependency managed-pages has) — only legal-pages/
|
|
110
110
|
// managed-pages are genuinely optional (merged only when opted in).
|
|
111
|
-
r.requires("config");
|
|
111
|
+
r.requires({ features: ["config"] });
|
|
112
112
|
r.optionalRequires("legal-pages", "managed-pages");
|
|
113
113
|
r.config({ keys: SEO_CONFIG_KEYS });
|
|
114
114
|
r.queryHandler(seoConfigQuery);
|
package/dist/sessions/feature.js
CHANGED
|
@@ -59,7 +59,7 @@ export function createSessionsFeature(options) {
|
|
|
59
59
|
// sessions-without-user wiring fails validateBoot instead of 500ing live.
|
|
60
60
|
// auth-foundation owns EXT_SESSION_STORE, which the useExtension below
|
|
61
61
|
// registers against.
|
|
62
|
-
r.requires("user", "auth-foundation");
|
|
62
|
+
r.requires({ features: ["user", "auth-foundation"] });
|
|
63
63
|
// store_user_sessions is a hot-path direct-write store: sessionCreator
|
|
64
64
|
// inserts and the revoke handlers update rows WITHOUT emitting lifecycle
|
|
65
65
|
// events (the row columns ARE the audit trail). Registering it as
|
|
@@ -144,21 +144,25 @@ export function createSessionsFeature(options) {
|
|
|
144
144
|
// regardless.
|
|
145
145
|
const REVOKE_TRIGGERING_FIELDS = ["passwordHash"];
|
|
146
146
|
let autoRevoke = options?.autoRevokeOnPasswordChange;
|
|
147
|
-
r.hook(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
147
|
+
r.hook({
|
|
148
|
+
type: "postSave",
|
|
149
|
+
target: { allOf: "user" },
|
|
150
|
+
handler: async (ctx) => {
|
|
151
|
+
// skip: nothing bound — stateless-JWT deployments without a runtime
|
|
152
|
+
// that calls bindAutoRevokeOnPasswordChange keep the old behavior.
|
|
153
|
+
if (!autoRevoke)
|
|
154
|
+
return;
|
|
155
|
+
// skip: brand-new user, no sessions can possibly exist yet. The
|
|
156
|
+
// initial passwordHash on a user:create would trip the second guard
|
|
157
|
+
// otherwise — every registration would do a mass-revoke roundtrip
|
|
158
|
+
// for a user who literally has no rows in user_sessions.
|
|
159
|
+
if (ctx.isNew)
|
|
160
|
+
return;
|
|
161
|
+
// skip: handler didn't touch any revoke-triggering field
|
|
162
|
+
if (!REVOKE_TRIGGERING_FIELDS.some((field) => ctx.changes[field] !== undefined))
|
|
163
|
+
return;
|
|
164
|
+
await autoRevoke(String(ctx.id));
|
|
165
|
+
},
|
|
162
166
|
});
|
|
163
167
|
const bindAutoRevokeOnPasswordChange = (revoker) => {
|
|
164
168
|
// explicit constructor option wins over the runtime binding
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const STEP_DISPATCHER_FEATURE = "step-dispatcher";
|
|
2
|
+
export declare const StepDispatcherJobNames: {
|
|
3
|
+
readonly deliver: "deliver";
|
|
4
|
+
};
|
|
5
|
+
export declare const StepDispatcherJobs: {
|
|
6
|
+
readonly deliver: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function deliverJobId(aggregateId: string): string;
|
|
9
|
+
export declare const DELIVER_MAX_PER_TENANT = 2;
|
|
10
|
+
export declare const DELIVER_RETRIES = 3;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
export const STEP_DISPATCHER_FEATURE = "step-dispatcher";
|
|
3
|
+
export const StepDispatcherJobNames = {
|
|
4
|
+
deliver: "deliver",
|
|
5
|
+
};
|
|
6
|
+
export const StepDispatcherJobs = {
|
|
7
|
+
deliver: qn(STEP_DISPATCHER_FEATURE, QnTypes.job, StepDispatcherJobNames.deliver),
|
|
8
|
+
};
|
|
9
|
+
// Stable per dispatch stream: BullMQ ignores a second add() with the same id,
|
|
10
|
+
// so an MSP redelivery cannot enqueue a second delivery.
|
|
11
|
+
export function deliverJobId(aggregateId) {
|
|
12
|
+
return `step-dispatch-${aggregateId}`;
|
|
13
|
+
}
|
|
14
|
+
// Per-tenant cap on simultaneous deliveries, so one tenant's slow receivers
|
|
15
|
+
// cannot occupy every worker slot.
|
|
16
|
+
export const DELIVER_MAX_PER_TENANT = 2;
|
|
17
|
+
export const DELIVER_RETRIES = 3;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
2
|
+
import { type TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
3
|
+
export declare function isDispatchOutcome(e: {
|
|
4
|
+
readonly type: string;
|
|
5
|
+
}): boolean;
|
|
6
|
+
export declare function eraseDispatchKey(aggregateId: string): Promise<void>;
|
|
7
|
+
export type DeliverDispatchRequestArgs = {
|
|
8
|
+
readonly aggregateId: string;
|
|
9
|
+
readonly tenantId: TenantId;
|
|
10
|
+
readonly attempt: number;
|
|
11
|
+
readonly finalAttempt: boolean;
|
|
12
|
+
readonly applyContextFor: (userId: string) => MultiStreamApplyContext;
|
|
13
|
+
};
|
|
14
|
+
export declare function deliverDispatchRequest(args: DeliverDispatchRequestArgs): Promise<void>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
5
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
6
|
+
import { redactEmailAddresses } from "../shared/index.js";
|
|
7
|
+
import { buildDispatchSpec, dispatchRequestedPayloadSchema, rawStepKindOf, readPayloadFields, } from "./dispatch-payload.js";
|
|
8
|
+
import { performMailDispatch } from "./mail-runner.js";
|
|
9
|
+
import { performWebhookDispatch } from "./webhook-runner.js";
|
|
10
|
+
const log = createFallbackLogger("step-dispatcher");
|
|
11
|
+
// zod issue messages can echo the invalid value (e.g. a rejected url) back
|
|
12
|
+
// into the tenant-visible dispatch-failed event — keep this generic.
|
|
13
|
+
const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
|
|
14
|
+
const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
15
|
+
// Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
|
|
16
|
+
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
17
|
+
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
18
|
+
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
19
|
+
// Thrown messages land in BullMQ's failedReason and the run log, so this never
|
|
20
|
+
// carries the receiver's error text or the target url.
|
|
21
|
+
const DELIVERY_RETRY_ERROR = "step delivery failed, retrying";
|
|
22
|
+
const DISPATCH_THREW_ERROR = "delivery attempt threw unexpectedly";
|
|
23
|
+
const FINAL_ATTEMPT_ABORTED_ERROR = "delivery aborted on the final attempt";
|
|
24
|
+
const FINAL_ATTEMPT_UNRECORDED_ERROR = "step delivery failed and its outcome could not be recorded";
|
|
25
|
+
export function isDispatchOutcome(e) {
|
|
26
|
+
return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
|
|
27
|
+
}
|
|
28
|
+
export async function eraseDispatchKey(aggregateId) {
|
|
29
|
+
await configuredPiiSubjectKms()?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: aggregateId }, {
|
|
30
|
+
requestId: requestContext.get()?.requestId ?? "step-dispatcher",
|
|
31
|
+
eraseReason: "step-dispatch-outcome-recorded",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// Mail adapter failures carry no status to classify, so every one is retried.
|
|
35
|
+
function mailResultAsDelivery(result) {
|
|
36
|
+
return result.ok ? result : { ...result, retryable: true };
|
|
37
|
+
}
|
|
38
|
+
// Outcome events are plaintext and generic; the request payload's per-dispatch
|
|
39
|
+
// key is erased right after, so the PII dies with the dispatch instead of
|
|
40
|
+
// living in the event log. The outcome is appended before the erase on
|
|
41
|
+
// purpose: if the erase throws, the outcome survives and a redelivery only
|
|
42
|
+
// repeats the erase. A crash between the send and the outcome append still
|
|
43
|
+
// re-sends under the same Idempotency-Key — the one remaining double-send case.
|
|
44
|
+
// A transient failure before the final attempt throws without an outcome or
|
|
45
|
+
// erase: the key must survive until the outcome is final.
|
|
46
|
+
export async function deliverDispatchRequest(args) {
|
|
47
|
+
const { aggregateId, finalAttempt } = args;
|
|
48
|
+
const stream = await args.applyContextFor(SYSTEM_USER_ID).loadAggregate(aggregateId);
|
|
49
|
+
if (stream.some(isDispatchOutcome)) {
|
|
50
|
+
await eraseDispatchKey(aggregateId);
|
|
51
|
+
// skip: redelivery after an outcome was recorded — only the erase is repeated
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const requested = stream.find((e) => e.type === STEP_DISPATCH_REQUESTED_TYPE);
|
|
55
|
+
if (!requested) {
|
|
56
|
+
// The key ref carries no tenant, so a job triggered with a foreign or
|
|
57
|
+
// unknown aggregateId must not erase anything or append to that stream.
|
|
58
|
+
log.warn("step dispatch request not found, nothing delivered", { aggregateId });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const ctx = args.applyContextFor(requested.metadata.userId || SYSTEM_USER_ID);
|
|
62
|
+
const recorder = createOutcomeRecorder(ctx, aggregateId, args.attempt);
|
|
63
|
+
try {
|
|
64
|
+
await deliverRequested(args, requested, ctx, recorder);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (!finalAttempt)
|
|
68
|
+
throw err;
|
|
69
|
+
await abortOnFinalAttempt(err, ctx, recorder, aggregateId, requested.payload);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function createOutcomeRecorder(ctx, aggregateId, attempt) {
|
|
73
|
+
const record = async (type, payload) => {
|
|
74
|
+
await ctx.unsafeAppendEvent({
|
|
75
|
+
aggregateId,
|
|
76
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
77
|
+
type,
|
|
78
|
+
payload,
|
|
79
|
+
});
|
|
80
|
+
await eraseDispatchKey(aggregateId);
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
record,
|
|
84
|
+
recordFailure: (stepKind, error) => record(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function deliverRequested(args, requested, ctx, recorder) {
|
|
88
|
+
const { aggregateId, tenantId, attempt, finalAttempt } = args;
|
|
89
|
+
const { record, recordFailure } = recorder;
|
|
90
|
+
const parsed = dispatchRequestedPayloadSchema.safeParse(requested.payload);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
return recordFailure(rawStepKindOf(requested.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
93
|
+
}
|
|
94
|
+
const payload = parsed.data;
|
|
95
|
+
const read = await readPayloadFields(payload);
|
|
96
|
+
if (read.kind === "unreadable")
|
|
97
|
+
return recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
|
|
98
|
+
if (read.kind === "erased")
|
|
99
|
+
return recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
100
|
+
const dispatchSpec = buildDispatchSpec(payload, read.fields);
|
|
101
|
+
if (!dispatchSpec)
|
|
102
|
+
return recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
103
|
+
const result = await sendDispatch(dispatchSpec, {
|
|
104
|
+
aggregateId,
|
|
105
|
+
tenantId,
|
|
106
|
+
userId: requested.metadata.userId || SYSTEM_USER_ID,
|
|
107
|
+
secrets: ctx.secrets,
|
|
108
|
+
});
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
return record(STEP_DISPATCHED_TYPE, { stepKind: payload.stepKind, status: result.status });
|
|
111
|
+
}
|
|
112
|
+
const loggedReason = payload.stepKind === "mail.send" ? redactEmailAddresses(result.error) : result.error;
|
|
113
|
+
if (result.retryable && !finalAttempt) {
|
|
114
|
+
log.warn("step delivery failed, will retry", { aggregateId, attempt, reason: loggedReason });
|
|
115
|
+
throw new Error(DELIVERY_RETRY_ERROR);
|
|
116
|
+
}
|
|
117
|
+
if (payload.stepKind === "mail.send") {
|
|
118
|
+
log.warn("mail dispatch failed", { aggregateId, reason: loggedReason });
|
|
119
|
+
return recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
120
|
+
}
|
|
121
|
+
return recordFailure(payload.stepKind, result.error);
|
|
122
|
+
}
|
|
123
|
+
async function sendDispatch(dispatchSpec, target) {
|
|
124
|
+
try {
|
|
125
|
+
return dispatchSpec.stepKind === "webhook.send"
|
|
126
|
+
? await performWebhookDispatch(dispatchSpec.spec, {
|
|
127
|
+
tenantId: target.tenantId,
|
|
128
|
+
userId: target.userId,
|
|
129
|
+
secrets: target.secrets,
|
|
130
|
+
idempotencyKey: target.aggregateId,
|
|
131
|
+
})
|
|
132
|
+
: await performMailDispatch(dispatchSpec.spec).then(mailResultAsDelivery);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// err.message can echo the url or an address; only the class name is logged.
|
|
136
|
+
log.warn("step delivery threw", {
|
|
137
|
+
aggregateId: target.aggregateId,
|
|
138
|
+
errorName: err instanceof Error ? err.name : "unknown",
|
|
139
|
+
});
|
|
140
|
+
return { ok: false, error: DISPATCH_THREW_ERROR, retryable: true };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function abortOnFinalAttempt(err, ctx, recorder, aggregateId, requestedPayload) {
|
|
144
|
+
// err.message can echo the url or an address; only the class name is logged.
|
|
145
|
+
log.warn("step delivery aborted on the final attempt", {
|
|
146
|
+
aggregateId,
|
|
147
|
+
errorName: err instanceof Error ? err.name : "unknown",
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
// An outcome may already be appended when only the erase threw: repeat the
|
|
151
|
+
// erase, never a second outcome.
|
|
152
|
+
const current = await ctx.loadAggregate(aggregateId);
|
|
153
|
+
if (current.some(isDispatchOutcome)) {
|
|
154
|
+
await eraseDispatchKey(aggregateId);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
await recorder.recordFailure(rawStepKindOf(requestedPayload), FINAL_ATTEMPT_ABORTED_ERROR);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new Error(FINAL_ATTEMPT_UNRECORDED_ERROR);
|
|
162
|
+
}
|
|
163
|
+
}
|