@cosmicdrift/kumiko-bundled-features 0.365.1 → 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.
Files changed (104) hide show
  1. package/dist/admin-shell/feature.js +9 -9
  2. package/dist/agent-tools/tool-dispatch.d.ts +9 -1
  3. package/dist/agent-tools/tool-dispatch.js +55 -5
  4. package/dist/audit/feature.js +1 -1
  5. package/dist/auth-email-password/constants.d.ts +0 -1
  6. package/dist/auth-email-password/constants.js +0 -2
  7. package/dist/auth-email-password/feature.js +3 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
  9. package/dist/auth-mfa/feature.js +4 -4
  10. package/dist/auth-mfa-user-data/index.js +1 -1
  11. package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
  12. package/dist/billing-foundation/feature.js +1 -1
  13. package/dist/cap-counter/cap-reservation.js +73 -25
  14. package/dist/cap-counter/constants.d.ts +1 -0
  15. package/dist/cap-counter/constants.js +2 -0
  16. package/dist/cap-counter/feature.js +2 -2
  17. package/dist/cap-counter/tables.js +5 -2
  18. package/dist/cap-overview/feature.js +3 -3
  19. package/dist/channel-discord/feature.js +2 -2
  20. package/dist/channel-email/feature.js +1 -1
  21. package/dist/channel-in-app/feature.js +1 -1
  22. package/dist/channel-push/feature.js +1 -1
  23. package/dist/channel-slack/feature.js +2 -2
  24. package/dist/channel-teams/feature.js +2 -2
  25. package/dist/channel-telegram/feature.js +2 -2
  26. package/dist/compliance-profiles/feature.js +1 -1
  27. package/dist/compliance-profiles-ops/index.js +3 -3
  28. package/dist/config/feature.js +1 -1
  29. package/dist/custom-fields/feature.js +2 -2
  30. package/dist/custom-fields/wire-for-entity.js +17 -13
  31. package/dist/data-retention/feature.js +1 -1
  32. package/dist/data-retention/presets.d.ts +4 -4
  33. package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
  34. package/dist/delivery/db/queries/address-opt-outs.js +6 -2
  35. package/dist/delivery/db/queries/preferences.d.ts +1 -1
  36. package/dist/delivery/db/queries/preferences.js +4 -4
  37. package/dist/delivery/delivery-service.js +9 -2
  38. package/dist/delivery/feature.js +9 -5
  39. package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
  40. package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
  41. package/dist/delivery/handlers/set-preference.write.js +3 -1
  42. package/dist/delivery/handlers/unsubscribe-address.write.js +3 -1
  43. package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
  44. package/dist/delivery/notification-type-aliases.d.ts +3 -0
  45. package/dist/delivery/notification-type-aliases.js +11 -0
  46. package/dist/derivatives-sharp/feature.js +1 -1
  47. package/dist/document-ingest-foundation/feature.js +2 -2
  48. package/dist/file-derivatives/feature.js +3 -3
  49. package/dist/file-foundation/feature.js +1 -1
  50. package/dist/file-provider-inmemory/feature.js +2 -2
  51. package/dist/file-provider-s3/feature.js +3 -3
  52. package/dist/file-provider-s3-env/feature.js +2 -2
  53. package/dist/files-tenant-data/index.js +1 -1
  54. package/dist/folders/feature.js +2 -2
  55. package/dist/folders-user-data/index.js +1 -1
  56. package/dist/form-draft/feature.js +2 -2
  57. package/dist/form-draft-user-data/index.js +1 -1
  58. package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
  59. package/dist/inbound-mail-foundation/connect-routes.js +6 -1
  60. package/dist/inbound-mail-foundation/feature.js +1 -1
  61. package/dist/inbound-mail-foundation/types.d.ts +17 -7
  62. package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
  63. package/dist/inbound-provider-imap/feature.js +2 -2
  64. package/dist/inbound-provider-inmemory/feature.js +1 -1
  65. package/dist/ledger/feature.js +5 -5
  66. package/dist/legal-pages/feature.js +1 -1
  67. package/dist/mail-foundation/feature.js +1 -1
  68. package/dist/mail-transport-inmemory/feature.js +2 -2
  69. package/dist/mail-transport-smtp/feature.js +3 -3
  70. package/dist/managed-pages/css-gate.js +1 -1
  71. package/dist/managed-pages/feature.js +2 -2
  72. package/dist/metrics/feature.js +2 -2
  73. package/dist/notes-history/feature.js +2 -2
  74. package/dist/notes-history-user-data/index.js +1 -1
  75. package/dist/personal-access-tokens/feature.js +17 -13
  76. package/dist/readiness/feature.js +2 -2
  77. package/dist/renderer-foundation/feature.js +1 -1
  78. package/dist/renderer-pdf/feature.js +1 -1
  79. package/dist/secrets/feature.js +1 -1
  80. package/dist/seo/feature.js +1 -1
  81. package/dist/sessions/feature.js +20 -16
  82. package/dist/step-dispatcher/feature.js +1 -1
  83. package/dist/subscription-mollie/feature.js +1 -1
  84. package/dist/subscription-stripe/feature.js +3 -3
  85. package/dist/tags/feature.js +2 -2
  86. package/dist/template-resolver/feature.js +2 -2
  87. package/dist/template-resolver-user-data/index.js +1 -1
  88. package/dist/tenant/feature.js +4 -4
  89. package/dist/tenant/seeding.js +1 -1
  90. package/dist/tenant-lifecycle/feature.js +2 -3
  91. package/dist/tenant-settings/feature.js +1 -1
  92. package/dist/tier-engine/feature.js +86 -72
  93. package/dist/user/feature.js +1 -1
  94. package/dist/user-data-rights/feature.js +4 -4
  95. package/dist/user-data-rights-defaults/feature.js +2 -2
  96. package/dist/user-profile/feature.js +4 -4
  97. package/dist/waitlist/feature.js +2 -2
  98. package/package.json +10 -10
  99. package/src/agent-tools/changes.json +6 -0
  100. package/src/auth-email-password/changes.json +13 -0
  101. package/src/cap-counter/changes.json +7 -0
  102. package/src/crypto-shredding/changes.json +6 -0
  103. package/src/delivery/changes.json +6 -0
  104. package/src/inbound-mail-foundation/changes.json +13 -0
@@ -24,7 +24,7 @@ export function createConfigFeature() {
24
24
  // projection write in one TX. Subscribers that need config-change
25
25
  // semantics listen to those auto-events via r.multiStreamProjection
26
26
  // (see docs/plans/architecture/event-sourcing-pivot.md §4.7).
27
- r.entity("config-value", configValueEntity);
27
+ r.entity({ name: "config-value", ...configValueEntity });
28
28
  const handlers = {
29
29
  set: r.writeHandler(setWrite),
30
30
  reset: r.writeHandler(resetWrite),
@@ -10,7 +10,7 @@
10
10
  // (crypto-shredding, event ciphertext, forget pipeline).
11
11
  //
12
12
  // **Was diese Feature liefert (B1 + B2, 2026-05-23):**
13
- // 1. r.entity("field-definition") — Definition-Storage (event-sourced).
13
+ // 1. r.entity({ name: "field-definition" }) — Definition-Storage (event-sourced).
14
14
  // 2. define-tenant-field / define-system-field — RBAC write-handlers für
15
15
  // Definition-CRUD.
16
16
  // 3. delete-tenant-field / delete-system-field — RBAC write-handlers.
@@ -75,7 +75,7 @@ function registerCustomFields(r, variant) {
75
75
  category: "data",
76
76
  recommended: false,
77
77
  });
78
- r.entity("field-definition", fieldDefinitionEntity);
78
+ r.entity({ name: "field-definition", ...fieldDefinitionEntity });
79
79
  // Event-types — qualified als "custom-fields:event:<short-name>".
80
80
  // Returned EventDefs liefern .name als compile-time literal-typed string,
81
81
  // den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
@@ -28,8 +28,8 @@ export function customFieldsField() {
28
28
  // Consumer-side:
29
29
  //
30
30
  // defineFeature("property-mgmt", (r) => {
31
- // r.entity("property", propertyEntity); // muss customFieldsField() haben
32
- // r.requires(CUSTOM_FIELDS_FEATURE_NAME);
31
+ // r.entity({ name: "property", ...propertyEntity }); // muss customFieldsField() haben
32
+ // r.requires({ features: [CUSTOM_FIELDS_FEATURE_NAME] });
33
33
  // wireCustomFieldsFor(r, "property", propertyTable);
34
34
  // });
35
35
  //
@@ -158,18 +158,22 @@ export function wireCustomFieldsFor(r, entityName, entityTable) {
158
158
  });
159
159
  // postQuery-hook: flatten row.customFields jsonb auf root-level der
160
160
  // API-response. Spec-Promise Z.4 "indistinguishable von Stammfeldern".
161
- r.hook("postQuery", { allOf: entityName }, async ({ rows }) => ({
162
- rows: rows.map((row) => {
163
- const customFields = row["customFields"];
164
- if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
165
- return {
166
- ...customFields, // @cast-boundary db-row jsonb runtime-untyped
167
- ...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
168
- };
169
- }
170
- return row;
161
+ r.hook({
162
+ type: "postQuery",
163
+ target: { allOf: entityName },
164
+ handler: async ({ rows }) => ({
165
+ rows: rows.map((row) => {
166
+ const customFields = row["customFields"];
167
+ if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
168
+ return {
169
+ ...customFields, // @cast-boundary db-row jsonb runtime-untyped
170
+ ...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
171
+ };
172
+ }
173
+ return row;
174
+ }),
171
175
  }),
172
- }));
176
+ });
173
177
  // Search-Payload-Extension: customFields-keys flach ins Index-Doc.
174
178
  // Anders als postQuery-hook (der ALLE keys merged) trägt der Search-
175
179
  // Contributor nur die als searchable=true definierten fields bei. v1
@@ -37,7 +37,7 @@ export function createDataRetentionFeature() {
37
37
  category: "compliance",
38
38
  recommended: false,
39
39
  });
40
- r.entity("tenant-retention-override", tenantRetentionOverrideEntity);
40
+ r.entity({ name: "tenant-retention-override", ...tenantRetentionOverrideEntity });
41
41
  // S2.D3: Cross-Feature-API fuer Forget-Flow + Cleanup-Job
42
42
  r.exposesApi("retention.policyFor");
43
43
  r.queryHandler(policyForQuery);
@@ -1,9 +1,9 @@
1
1
  import type { RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  /**
3
- * Pro Entity-Name gemappte Retention-Policy. Entity-Namen sind der
4
- * String aus r.entity("name", ...) — kebab-case oder lowercase.
5
- * Cleanup-Job iteriert alle bekannten Entities; was nicht im Preset
6
- * steht, fällt auf Entity-Default (Layer 1) zurück.
3
+ * Retention policy keyed by entity name, i.e. the `name` from
4
+ * r.entity({ name, ... }) (kebab-case or lowercase). The cleanup job walks
5
+ * all known entities; anything missing from the preset falls back to the
6
+ * entity default (layer 1).
7
7
  */
8
8
  export type RetentionPreset = Readonly<Record<string, RetentionDef>>;
9
9
  export type RetentionPresetKey = "default" | "dsgvo-basic" | "dsgvo-hgb" | "swiss-dsg";
@@ -2,4 +2,4 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  export type AddressOptOutKey = string;
4
4
  export declare function addressOptOutKey(addressHash: string, channel: string): AddressOptOutKey;
5
- export declare function selectOptedOutAddresses(db: DbConnection, tenantId: TenantId, notificationType: string, addressHashes: readonly string[]): Promise<ReadonlySet<AddressOptOutKey>>;
5
+ export declare function selectOptedOutAddresses(db: DbConnection, tenantId: TenantId, notificationTypeNames: readonly string[], addressHashes: readonly string[]): Promise<ReadonlySet<AddressOptOutKey>>;
@@ -7,9 +7,13 @@ export function addressOptOutKey(addressHash, channel) {
7
7
  // ("*") semantics for address opt-outs, the token that creates a row always
8
8
  // carries one concrete notificationType/channel pair. One query covers every
9
9
  // recipient of a notify() call.
10
- export async function selectOptedOutAddresses(db, tenantId, notificationType, addressHashes) {
10
+ export async function selectOptedOutAddresses(db, tenantId, notificationTypeNames, addressHashes) {
11
11
  if (addressHashes.length === 0)
12
12
  return new Set();
13
- const rows = await selectMany(db, notificationAddressOptOutsTable, { tenantId, notificationType, addressHash: { in: addressHashes } });
13
+ const rows = await selectMany(db, notificationAddressOptOutsTable, {
14
+ tenantId,
15
+ notificationType: { in: notificationTypeNames },
16
+ addressHash: { in: addressHashes },
17
+ });
14
18
  return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
15
19
  }
@@ -5,4 +5,4 @@ export type NotificationPreferenceRow = {
5
5
  readonly channel: string;
6
6
  readonly enabled: boolean;
7
7
  };
8
- export declare function selectNotificationPreferences(db: DbConnection, tenantId: TenantId, userId: string, notificationType: string, channelName: string): Promise<readonly NotificationPreferenceRow[]>;
8
+ export declare function selectNotificationPreferences(db: DbConnection, tenantId: TenantId, userId: string, notificationTypeNames: readonly string[], channelName: string): Promise<readonly NotificationPreferenceRow[]>;
@@ -1,13 +1,13 @@
1
1
  import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  // @wrapper-known semantic-alias
3
- export async function selectNotificationPreferences(db, tenantId, userId, notificationType, channelName) {
3
+ export async function selectNotificationPreferences(db, tenantId, userId, notificationTypeNames, channelName) {
4
4
  return unsafeReadRetrying(db, `SELECT notification_type AS "notificationType", channel, enabled
5
5
  FROM read_notification_preferences
6
6
  WHERE tenant_id = $1
7
7
  AND user_id = $2
8
8
  AND (
9
- (notification_type = $3 AND channel = $4)
9
+ (notification_type = ANY($3::text[]) AND channel = $4)
10
10
  OR (notification_type = '*' AND channel = $4)
11
- OR (notification_type = $3 AND channel = '*')
12
- )`, [tenantId, userId, notificationType, channelName]);
11
+ OR (notification_type = ANY($3::text[]) AND channel = '*')
12
+ )`, [tenantId, userId, notificationTypeNames, channelName]);
13
13
  }
@@ -12,6 +12,7 @@ import { buildChannelContext } from "./channel-context.js";
12
12
  import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
13
13
  import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
14
14
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
15
+ import { notificationTypeNames } from "./notification-type-aliases.js";
15
16
  import { isDeliveryChannelPlugin, } from "./types.js";
16
17
  export function redactedMessageOf(err) {
17
18
  return redactErrorText(err instanceof Error ? err.message : String(err));
@@ -292,7 +293,7 @@ export function createDeliveryService(options) {
292
293
  if (addressHash !== undefined)
293
294
  addressHashes.add(addressHash);
294
295
  }
295
- return selectOptedOutAddresses(run.db, tenantId, notificationType, [...addressHashes]);
296
+ return selectOptedOutAddresses(run.db, tenantId, notificationTypeNames(registry, notificationType), [...addressHashes]);
296
297
  }
297
298
  function isAddressSuppressed(suppressed, address, channelName) {
298
299
  const addressHash = hashUnsubscribeAddress(address);
@@ -354,13 +355,19 @@ export function createDeliveryService(options) {
354
355
  // { type: "orderAssigned", channel: "*", enabled: true } enables orderAssigned everywhere
355
356
  // → orderAssigned on inApp: disabled (conservative) unless an exact entry overrides.
356
357
  async function isChannelEnabled(run, userId, tenantId, notificationType, channelName) {
357
- const prefs = await selectNotificationPreferences(run.db, tenantId, userId, notificationType, channelName);
358
+ const prefs = await selectNotificationPreferences(run.db, tenantId, userId, notificationTypeNames(registry, notificationType), channelName);
358
359
  if (prefs.length === 0)
359
360
  return true;
360
361
  // Exact match (both specific) wins over any wildcard
361
362
  const exact = prefs.find((p) => p.notificationType === notificationType && p.channel === channelName);
362
363
  if (exact)
363
364
  return exact.enabled;
365
+ // Rows stored under a former name: the user's choice from before the rename
366
+ const aliasExact = prefs.filter((p) => p.notificationType !== "*" &&
367
+ p.notificationType !== notificationType &&
368
+ p.channel === channelName);
369
+ if (aliasExact.length > 0)
370
+ return !aliasExact.some((p) => p.enabled === false);
364
371
  // Only wildcards matched: any disabled entry disables delivery (deterministic
365
372
  // and conservative — DB ordering no longer decides the outcome).
366
373
  return !prefs.some((p) => p.enabled === false);
@@ -27,18 +27,22 @@ export function createDeliveryFeature(options) {
27
27
  });
28
28
  r.systemScope();
29
29
  // delivery-log's tenantId column has refEntity: "tenant:tenant" (boot-checked).
30
- r.requires("tenant");
30
+ r.requires({ features: ["tenant"] });
31
31
  // Backing table: the (tenant,user,type,channel) uniqueIndex lives only on
32
32
  // the physical table, not on the entity fields, so the generator would
33
33
  // otherwise omit it → duplicate preference rows on concurrent upserts.
34
- r.entity("notification-preference", notificationPreferenceEntity, {
35
- table: notificationPreferencesTable,
34
+ r.entity({
35
+ name: "notification-preference",
36
+ ...notificationPreferenceEntity,
37
+ backingTable: notificationPreferencesTable,
36
38
  });
37
39
  // Same ride-along reason as notification-preference above: the
38
40
  // (tenant,addressHash,type,channel) uniqueIndex lives only on the
39
41
  // physical table.
40
- r.entity("notification-address-opt-out", notificationAddressOptOutEntity, {
41
- table: notificationAddressOptOutsTable,
42
+ r.entity({
43
+ name: "notification-address-opt-out",
44
+ ...notificationAddressOptOutEntity,
45
+ backingTable: notificationAddressOptOutsTable,
42
46
  });
43
47
  r.storeTable(deliveryAttemptsTableMeta, {
44
48
  reason: "read_side.delivery_attempt_log",
@@ -2,6 +2,7 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { removeAddressOptOut } from "../address-opt-out.js";
5
+ import { currentNotificationType, notificationTypeNames } from "../notification-type-aliases.js";
5
6
  export const resubscribeAddressWrite = defineWriteHandler({
6
7
  name: "resubscribeAddress",
7
8
  schema: z.object({
@@ -18,7 +19,8 @@ export const resubscribeAddressWrite = defineWriteHandler({
18
19
  agent: { expose: false, risk: "high" },
19
20
  description: "Removes a no-account address opt-out for one notificationType/channel combination; dispatched by the signed resubscribe route, not meant for UI callers.",
20
21
  handler: async (event, ctx) => {
21
- const { addressHash, notificationType, channel } = event.payload;
22
+ const { addressHash, channel } = event.payload;
23
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
22
24
  const { tenantId } = event.user;
23
25
  if (!ctx.systemDb) {
24
26
  throw new InternalError({
@@ -26,14 +28,16 @@ export const resubscribeAddressWrite = defineWriteHandler({
26
28
  });
27
29
  }
28
30
  const db = ctx.systemDb.assertTenantMatch(tenantId);
29
- const result = await removeAddressOptOut(db, event.user, {
30
- tenantId,
31
- addressHash,
32
- notificationType,
33
- channel,
34
- });
35
- if (!result.isSuccess)
36
- return result;
31
+ for (const name of notificationTypeNames(ctx.registry, notificationType)) {
32
+ const result = await removeAddressOptOut(db, event.user, {
33
+ tenantId,
34
+ addressHash,
35
+ notificationType: name,
36
+ channel,
37
+ });
38
+ if (!result.isSuccess)
39
+ return result;
40
+ }
37
41
  return { isSuccess: true, data: { notificationType, channel } };
38
42
  },
39
43
  });
@@ -1,6 +1,7 @@
1
1
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { currentNotificationType } from "../notification-type-aliases.js";
4
5
  import { upsertPreference } from "../upsert-preference.js";
5
6
  export const resubscribeUserWrite = defineWriteHandler({
6
7
  name: "resubscribeUser",
@@ -16,7 +17,8 @@ export const resubscribeUserWrite = defineWriteHandler({
16
17
  agent: { expose: false },
17
18
  description: "Re-enables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed resubscribe route, not meant for UI callers.",
18
19
  handler: async (event, ctx) => {
19
- const { userId, notificationType, channel } = event.payload;
20
+ const { userId, channel } = event.payload;
21
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
20
22
  const { tenantId } = event.user;
21
23
  if (!ctx.systemDb) {
22
24
  throw new InternalError({
@@ -1,6 +1,7 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { currentNotificationType } from "../notification-type-aliases.js";
4
5
  import { upsertPreference } from "../upsert-preference.js";
5
6
  export const setPreferenceWrite = defineWriteHandler({
6
7
  name: "setPreference",
@@ -18,7 +19,8 @@ export const setPreferenceWrite = defineWriteHandler({
18
19
  },
19
20
  description: "Enables or disables one notification type and channel combination for the calling user, where either side may be the wildcard * as a catch-all; use it when a user changes their own notification settings.",
20
21
  handler: async (event, ctx) => {
21
- const { notificationType, channel, enabled } = event.payload;
22
+ const { channel, enabled } = event.payload;
23
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
22
24
  const { id: userId, tenantId } = event.user;
23
25
  // delivery runs in system-scope (feature.ts r.systemScope()) — ctx.systemDb
24
26
  // is guaranteed there. No client-supplied userId exists on this payload;
@@ -2,6 +2,7 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { upsertAddressOptOut } from "../address-opt-out.js";
5
+ import { currentNotificationType } from "../notification-type-aliases.js";
5
6
  export const unsubscribeAddressWrite = defineWriteHandler({
6
7
  name: "unsubscribeAddress",
7
8
  schema: z.object({
@@ -16,7 +17,8 @@ export const unsubscribeAddressWrite = defineWriteHandler({
16
17
  agent: { expose: false },
17
18
  description: "Records a no-account address opt-out for one notificationType/channel combination; dispatched by the signed unsubscribe route, not meant for UI callers.",
18
19
  handler: async (event, ctx) => {
19
- const { addressHash, notificationType, channel } = event.payload;
20
+ const { addressHash, channel } = event.payload;
21
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
20
22
  const { tenantId } = event.user;
21
23
  if (!ctx.systemDb) {
22
24
  throw new InternalError({
@@ -1,6 +1,7 @@
1
1
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { currentNotificationType } from "../notification-type-aliases.js";
4
5
  import { upsertPreference } from "../upsert-preference.js";
5
6
  export const unsubscribeUserWrite = defineWriteHandler({
6
7
  name: "unsubscribeUser",
@@ -18,7 +19,8 @@ export const unsubscribeUserWrite = defineWriteHandler({
18
19
  handler: async (event, ctx) => {
19
20
  // userId comes from the payload, not event.user.id — the caller here is
20
21
  // the system user dispatchSystemWrite runs as, not the user unsubscribing.
21
- const { userId, notificationType, channel } = event.payload;
22
+ const { userId, channel } = event.payload;
23
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
22
24
  const { tenantId } = event.user;
23
25
  if (!ctx.systemDb) {
24
26
  throw new InternalError({
@@ -0,0 +1,3 @@
1
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function notificationTypeNames(registry: Registry, type: string): readonly string[];
3
+ export declare function currentNotificationType(registry: Registry, type: string): string;
@@ -0,0 +1,11 @@
1
+ export function notificationTypeNames(registry, type) {
2
+ const renamedFrom = registry.getAllNotifications().get(type)?.renamedFrom ?? [];
3
+ return [type, ...renamedFrom];
4
+ }
5
+ export function currentNotificationType(registry, type) {
6
+ for (const [name, definition] of registry.getAllNotifications()) {
7
+ if (definition.renamedFrom?.includes(type))
8
+ return name;
9
+ }
10
+ return type;
11
+ }
@@ -9,7 +9,7 @@ export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
9
9
  category: "storage",
10
10
  recommended: false,
11
11
  });
12
- r.requires("file-derivatives");
12
+ r.requires({ features: ["file-derivatives"] });
13
13
  const plugin = { render: renderImage };
14
14
  r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
15
15
  });
@@ -90,8 +90,8 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
90
90
  });
91
91
  // tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
92
92
  // tenant-subject ciphertext and needs the destroy hook below.
93
- r.requires("config", "tenant-lifecycle");
94
- r.entity("documentExtract", documentExtractEntity);
93
+ r.requires({ features: ["config", "tenant-lifecycle"] });
94
+ r.entity({ name: "documentExtract", ...documentExtractEntity });
95
95
  r.useExtension(EXT_TENANT_DATA, "documentExtract", {
96
96
  destroy: documentExtractTenantDestroyHook,
97
97
  });
@@ -53,12 +53,12 @@ export function createFileDerivativesFeature(opts = {}) {
53
53
  });
54
54
  // Needs a storage provider to read the original + write the variant —
55
55
  // without file-foundation there's nothing to derive from or write to.
56
- r.requires("file-foundation");
56
+ r.requires({ features: ["file-foundation"] });
57
57
  // variant() reads fileRefsTable, which "files" (not file-foundation)
58
58
  // owns — without this, mounting file-derivatives + file-foundation
59
59
  // alone boots fine but fails at request-time with a SQL error the first
60
60
  // time variant() runs.
61
- r.requires("files");
61
+ r.requires({ features: ["files"] });
62
62
  r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
63
63
  onRegister: () => {
64
64
  // No side-effects at register-time — resolution (exact MIME match,
@@ -103,7 +103,7 @@ export function createFileDerivativesFeature(opts = {}) {
103
103
  // tenant's public variants from staying reachable — require the
104
104
  // dependency so a missing mount fails boot, not the tenant check
105
105
  // silently no-op-ing at request time.
106
- r.requires("tenant");
106
+ r.requires({ features: ["tenant"] });
107
107
  r.queryHandler(publicVariantByFileRefQuery);
108
108
  }
109
109
  r.httpRoute({
@@ -27,7 +27,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
27
27
  category: "storage",
28
28
  recommended: false,
29
29
  });
30
- r.requires("config");
30
+ r.requires({ features: ["config"] });
31
31
  r.extendsRegistrar(EXT_FILE_PROVIDER, {
32
32
  onRegister: () => {
33
33
  // No side-effects at register-time — registry stores the usage,
@@ -49,10 +49,10 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
49
49
  category: "storage",
50
50
  recommended: false,
51
51
  });
52
- // Kein r.requires("config") + kein r.requires("secrets") — der
52
+ // Kein requires auf "config" oder "secrets" — der
53
53
  // In-Memory-Provider hat keine Config + kein Secret. Nur die
54
54
  // file-foundation muss da sein (Plugin-extension-point).
55
- r.requires("file-foundation");
55
+ r.requires({ features: ["file-foundation"] });
56
56
  const plugin = {
57
57
  build: async (_ctx, tenantId) => {
58
58
  // Returnt den per-tenant Storage. Identitätsstabil zwischen calls
@@ -32,9 +32,9 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
32
32
  category: "storage",
33
33
  recommended: false,
34
34
  });
35
- r.requires("config");
36
- r.requires("secrets");
37
- r.requires("file-foundation");
35
+ r.requires({ features: ["config"] });
36
+ r.requires({ features: ["secrets"] });
37
+ r.requires({ features: ["file-foundation"] });
38
38
  const secretAccessKey = r.secret("s3.secretAccessKey", {
39
39
  label: { en: "S3 secret access key" },
40
40
  hint: {
@@ -32,10 +32,10 @@ export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
32
32
  category: "storage",
33
33
  recommended: false,
34
34
  });
35
- // No r.requires("config") / r.requires("secrets") — credentials come from
35
+ // No requires on "config" or "secrets" — credentials come from
36
36
  // env, not the per-tenant config + secrets store. Only the file-foundation
37
37
  // plugin-extension-point must be mounted.
38
- r.requires("file-foundation");
38
+ r.requires({ features: ["file-foundation"] });
39
39
  const plugin = {
40
40
  // tenantId ignored: one shared bucket serves all tenants (isolation via
41
41
  // tenant-prefixed / UUID keys). Built fresh per call like file-provider-s3;
@@ -23,7 +23,7 @@ export function createFilesTenantDataFeature() {
23
23
  category: "compliance",
24
24
  recommended: false,
25
25
  });
26
- r.requires("tenant-lifecycle");
26
+ r.requires({ features: ["tenant-lifecycle"] });
27
27
  r.optionalRequires("files");
28
28
  r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy: fileRefTenantDestroyHook });
29
29
  r.useExtension(EXT_STORAGE_PROVIDER, "fileRef", { destroyTenant: fileRefStorageDestroyHook });
@@ -31,8 +31,8 @@ function registerFolders(r, access, toggleable, ownership, parents) {
31
31
  if (toggleable !== undefined)
32
32
  r.toggleable(toggleable);
33
33
  const folderAssignmentEntity = createFolderAssignmentEntity(ownership, parents);
34
- r.entity("folder", folderEntity);
35
- r.entity("folder-assignment", folderAssignmentEntity);
34
+ r.entity({ name: "folder", ...folderEntity });
35
+ r.entity({ name: "folder-assignment", ...folderAssignmentEntity });
36
36
  // Folder catalog — plain CRUD, no custom logic. update is rename (and, in a
37
37
  // later stage, reparent: it accepts changes.parentId, optimistic-locked).
38
38
  r.writeHandler(defineEntityCreateHandler("folder", folderEntity, {
@@ -11,7 +11,7 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
11
11
  // OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
12
12
  // tier), a hard r.requires would throw an "effectively disabled" boot
13
13
  // warning even though the folder entities exist and the hooks work fine.
14
- r.requires("user-data-rights");
14
+ r.requires({ features: ["user-data-rights"] });
15
15
  r.optionalRequires("folders");
16
16
  r.useExtension(EXT_USER_DATA, "folder", {
17
17
  export: folderExportHook,
@@ -23,11 +23,11 @@ function registerFormDraft(r) {
23
23
  // config is optional: the retention-days key only resolves when the
24
24
  // config feature is mounted (ctx.configResolver present) — the cleanup
25
25
  // job already falls back to FORM_DRAFT_DEFAULT_RETENTION_DAYS otherwise
26
- // (see handlers/cleanup.job.ts). A hard r.requires("config") would break
26
+ // (see handlers/cleanup.job.ts). A hard r.requires({ features: ["config"] }) would break
27
27
  // every app that mounts form-draft without config, which is a breaking
28
28
  // change no minor/patch release should introduce silently.
29
29
  r.optionalRequires("config");
30
- r.entity("form-draft", formDraftEntity);
30
+ r.entity({ name: "form-draft", ...formDraftEntity });
31
31
  r.writeHandler(saveDraftWrite);
32
32
  r.writeHandler(discardDraftWrite);
33
33
  r.queryHandler(getDraftQuery);
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
8
8
  import { formDraftDeleteHook, formDraftExportHook } from "./hooks.js";
9
9
  export const formDraftUserDataFeature = defineFeature("form-draft-user-data", (r) => {
10
10
  r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `form-draft` feature's `form-draft` entity. Mounts the EXT_USER_DATA export hook so a user's saved drafts are included in the user-data export bundle, and a delete hook that physically removes the user's own draft rows on forget. Kept separate from `form-draft` so form-draft consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `form-draft`.");
11
- r.requires("user-data-rights");
11
+ r.requires({ features: ["user-data-rights"] });
12
12
  r.optionalRequires("form-draft");
13
13
  r.useExtension(EXT_USER_DATA, "form-draft", {
14
14
  export: formDraftExportHook,
@@ -1,5 +1,6 @@
1
1
  import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { type OAuthAccessTokenManager } from "./oauth-access-token.js";
3
+ import type { InboundMailContext } from "./types.js";
3
4
  export type InboundMailConnectRoutesOptions = {
4
5
  /** HMAC-secret for the state param (deploy-time env — `scope:"system"`
5
6
  * secrets don't exist in secrets-v1). */
@@ -21,6 +22,8 @@ export type InboundMailConnectRoutesOptions = {
21
22
  * lock and primes the access-token cache from the code exchange. Omitted
22
23
  * → plain secret write, no cache priming. */
23
24
  readonly oauthTokens?: OAuthAccessTokenManager;
25
+ /** Handed to the provider plugin's context (`InboundMailContext.metricsFor`). */
26
+ readonly metricsFor?: InboundMailContext["metricsFor"];
24
27
  };
25
28
  /** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
26
29
  * `extraRoutes: createInboundMailConnectRoutes(options)`. */
@@ -80,7 +80,12 @@ export function createInboundMailConnectRoutes(options) {
80
80
  if (!deps.secrets) {
81
81
  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`);
82
82
  }
83
- const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
83
+ const providerCtx = {
84
+ registry: deps.registry,
85
+ secrets: deps.secrets,
86
+ config: undefined,
87
+ ...(options.metricsFor && { metricsFor: options.metricsFor }),
88
+ };
84
89
  let plugin;
85
90
  try {
86
91
  plugin = resolveInboundProviderForKey(providerCtx, state.providerKey);
@@ -62,7 +62,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
62
62
  recommended: false,
63
63
  });
64
64
  // EXT_TENANT_DATA-Hook braucht den tenant-lifecycle-Host.
65
- r.requires("tenant-lifecycle");
65
+ r.requires({ features: ["tenant-lifecycle"] });
66
66
  // 5 fine-grained domain-events (Payload-Schemas: events.ts).
67
67
  // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
68
68
  // "displayName" stays plaintext (often the mailbox owner's real name for
@@ -1,20 +1,25 @@
1
1
  import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
2
3
  import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "./constants.js";
3
4
  /**
4
- * Slim-Context für Provider-Plugins.
5
+ * Slim context for provider plugins.
5
6
  *
6
- * **Felder:**
7
- * config — tenant-config-reads (host/port/... der Plugins)
8
- * registry — extension-Lookup in der Factory (nicht plugin-intern)
9
- * secrets — tenant-secret-reads (IMAP-Passwort, OAuth-Refresh-Token)
10
- * _userId — Audit-Identity für secret-reads. Handler-Pfad: dispatcher
11
- * setzt Caller-User-ID; Worker-Pfad: System-Identity.
7
+ * **Fields:**
8
+ * config — tenant config reads (host/port/... of the plugins)
9
+ * registry — extension lookup in the factory (not plugin-internal)
10
+ * secrets — tenant secret reads (IMAP password, OAuth refresh token)
11
+ * _userId — audit identity for secret reads. Handler path: the dispatcher
12
+ * sets the caller user ID; worker path: system identity.
13
+ * metricsFor — metrics handle of a feature by name, same behavior as
14
+ * HandlerContext.metricsFor: unregistered metrics are a silent
15
+ * no-op instead of a throw.
12
16
  */
13
17
  export type InboundMailContext = {
14
18
  readonly config?: ConfigAccessor;
15
19
  readonly registry?: Registry;
16
20
  readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
17
21
  readonly _userId?: string | undefined;
22
+ readonly metricsFor?: (featureName: string) => MetricsHandle;
18
23
  };
19
24
  /**
20
25
  * Plaintext-Sicht auf ein verbundenes Postfach, wie Provider sie sehen.
@@ -141,6 +146,11 @@ export type InboundMailProviderPlugin = {
141
146
  * whose provider declares `oauth` (authMethod oauth/xoauth2). Absent
142
147
  * for password accounts and providers without an oauth flow. */
143
148
  readonly accessToken?: string;
149
+ /** Batch lookup of the provider message ids not yet ingested for this
150
+ * account (committed seen anchors, one query per call). Call it after
151
+ * the listing/delta page and download MIME only for the returned ids.
152
+ * Ingest dedup stays the correctness anchor; this only saves downloads. */
153
+ readonly filterUnknownMessageIds?: (providerMessageIds: readonly string[]) => Promise<ReadonlySet<string>>;
144
154
  }) => Promise<InboundFetchResult>;
145
155
  /** Nur OAuth-Provider. */
146
156
  readonly oauth?: InboundOAuthFlow;