@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.
- 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/feature.js +1 -1
- 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-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 +6 -0
- package/src/delivery/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +13 -0
package/dist/config/feature.js
CHANGED
|
@@ -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(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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,
|
|
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,
|
|
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, {
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|
package/dist/delivery/feature.js
CHANGED
|
@@ -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(
|
|
35
|
-
|
|
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(
|
|
41
|
-
|
|
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,
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
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 {
|
|
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,
|
|
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,
|
|
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,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
|
|
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
|
|
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 });
|
package/dist/folders/feature.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
5
|
+
* Slim context for provider plugins.
|
|
5
6
|
*
|
|
6
|
-
* **
|
|
7
|
-
* config — tenant
|
|
8
|
-
* registry — extension
|
|
9
|
-
* secrets — tenant
|
|
10
|
-
* _userId —
|
|
11
|
-
*
|
|
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;
|