@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.127.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/package.json +7 -6
- package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
- package/src/audit/i18n.ts +9 -0
- package/src/audit/web/audit-log-screen.tsx +112 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
- package/src/billing-foundation/entities.ts +28 -4
- package/src/billing-foundation/events.ts +6 -2
- package/src/billing-foundation/feature.ts +9 -1
- package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
- package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
- package/src/billing-foundation/handlers/process-event.write.ts +39 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
- package/src/cap-counter/entity.ts +1 -1
- package/src/cap-counter/feature.ts +10 -2
- package/src/cap-counter/i18n.ts +12 -0
- package/src/compliance-profiles/feature.ts +3 -0
- package/src/compliance-profiles/i18n.ts +6 -0
- package/src/config/feature.ts +3 -0
- package/src/config/i18n.ts +9 -0
- package/src/delivery/feature.ts +3 -0
- package/src/delivery/i18n.ts +6 -0
- package/src/feature-toggles/feature.ts +3 -0
- package/src/feature-toggles/i18n.ts +6 -0
- package/src/jobs/i18n.ts +4 -0
- package/src/jobs/web/job-runs-screen.tsx +9 -9
- package/src/managed-pages/i18n.ts +5 -0
- package/src/managed-pages/screens/page-screens.ts +5 -1
- package/src/personal-access-tokens/feature.ts +2 -0
- package/src/personal-access-tokens/i18n.ts +5 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
- package/src/subscription-stripe/feature.ts +2 -0
- package/src/tags/feature.ts +2 -0
- package/src/tags/i18n.ts +5 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
- package/src/tenant/feature.ts +4 -1
- package/src/tenant/handlers/members.query.ts +19 -4
- package/src/tenant/i18n.ts +28 -0
- package/src/tenant/index.ts +7 -2
- package/src/tenant/schema/tenant.ts +27 -2
- package/src/tenant/screens.ts +3 -2
- package/src/tenant/web/members-screen.tsx +8 -3
- package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
- package/src/tenant-lifecycle/constants.ts +56 -0
- package/src/tenant-lifecycle/events.ts +46 -0
- package/src/tenant-lifecycle/feature.ts +105 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
- package/src/tenant-lifecycle/index.ts +7 -0
- package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
- package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
- package/src/tenant-lifecycle/stages.ts +206 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/i18n.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
- package/src/user/feature.ts +3 -0
- package/src/user/i18n.ts +20 -0
- package/src/user/schema/user.ts +1 -0
- package/src/user/screens.ts +3 -15
- package/src/user-data-rights/feature.ts +17 -0
- package/src/user-data-rights/i18n.ts +32 -33
- package/src/user-data-rights/schema/export-job.ts +2 -0
- package/src/user-data-rights/screens.ts +2 -1
- package/src/user-data-rights/web/i18n.ts +9 -0
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
// kennt.
|
|
11
11
|
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import {
|
|
14
|
+
configuredPiiSubjectKms,
|
|
15
|
+
decryptPiiFieldValues,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
13
17
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
18
|
import { z } from "zod";
|
|
15
19
|
import { subscriptionAggregateId } from "../aggregate-id";
|
|
16
20
|
import { SUBSCRIPTION_PROVIDER_EXTENSION } from "../constants";
|
|
21
|
+
import { SUBSCRIPTION_PII_FIELDS } from "../entities";
|
|
17
22
|
import { subscriptionsProjectionTable as subTable } from "../projection";
|
|
18
23
|
import type { SubscriptionProviderPlugin } from "../types";
|
|
19
24
|
|
|
@@ -41,8 +46,19 @@ export const createPortalSessionHandler: WriteHandlerDef = {
|
|
|
41
46
|
"subscription-foundation: no active subscription for this tenant. Create one via create-checkout-session first.",
|
|
42
47
|
);
|
|
43
48
|
}
|
|
49
|
+
const piiKms = configuredPiiSubjectKms();
|
|
50
|
+
const decrypted = piiKms
|
|
51
|
+
? await decryptPiiFieldValues(
|
|
52
|
+
row as Record<string, unknown>,
|
|
53
|
+
SUBSCRIPTION_PII_FIELDS,
|
|
54
|
+
piiKms,
|
|
55
|
+
{
|
|
56
|
+
requestId: `billing-foundation:create-portal-session:${tenantId}`,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
: (row as Record<string, unknown>);
|
|
44
60
|
const providerName = row["providerName"] as string; // @cast-boundary db-row
|
|
45
|
-
const providerCustomerId =
|
|
61
|
+
const providerCustomerId = decrypted["providerCustomerId"] as string; // @cast-boundary db-row
|
|
46
62
|
|
|
47
63
|
// 2. Plugin-Lookup
|
|
48
64
|
const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
// tenant-scoped, gibt automatisch nur die row des Callers zurück).
|
|
5
5
|
|
|
6
6
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
+
import {
|
|
8
|
+
configuredPiiSubjectKms,
|
|
9
|
+
decryptPiiFieldValues,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
11
|
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
12
|
import { z } from "zod";
|
|
13
|
+
import { SUBSCRIPTION_PII_FIELDS } from "../entities";
|
|
9
14
|
import { subscriptionsProjectionTable } from "../projection";
|
|
10
15
|
|
|
11
16
|
const listSchema = z.object({}).strict();
|
|
@@ -18,6 +23,15 @@ export const listSubscriptionsQuery: QueryHandlerDef = {
|
|
|
18
23
|
const rows = await selectMany(ctx.db.raw, subscriptionsProjectionTable, {
|
|
19
24
|
tenantId: ctx.user.tenantId,
|
|
20
25
|
});
|
|
21
|
-
|
|
26
|
+
const piiKms = configuredPiiSubjectKms();
|
|
27
|
+
if (!piiKms) return { rows };
|
|
28
|
+
const decryptedRows = await Promise.all(
|
|
29
|
+
rows.map((row) =>
|
|
30
|
+
decryptPiiFieldValues(row as Record<string, unknown>, SUBSCRIPTION_PII_FIELDS, piiKms, {
|
|
31
|
+
requestId: `billing-foundation:list-subscriptions:${ctx.user.tenantId}`,
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
return { rows: decryptedRows };
|
|
22
36
|
},
|
|
23
37
|
};
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
// 3. ctx.unsafeAppendEvent — Inline-projection materialisiert die
|
|
13
13
|
// `read_subscriptions`-row in derselben TX.
|
|
14
14
|
|
|
15
|
+
import {
|
|
16
|
+
configuredPiiSubjectKms,
|
|
17
|
+
encryptPiiFieldValues,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
19
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
20
|
import { z } from "zod";
|
|
17
21
|
import { subscriptionAggregateId } from "../aggregate-id";
|
|
18
22
|
import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
|
|
23
|
+
import { SUBSCRIPTION_PII_FIELDS, subscriptionEntity } from "../entities";
|
|
19
24
|
import {
|
|
20
25
|
INVOICE_PAID_EVENT_QN,
|
|
21
26
|
INVOICE_PAYMENT_FAILED_EVENT_QN,
|
|
@@ -125,13 +130,44 @@ export const processEventHandler: WriteHandlerDef = {
|
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
// ---------------------------------------------------------------
|
|
128
|
-
// 3.
|
|
133
|
+
// 3. Encrypt the two provider-subject PII fields before they touch
|
|
134
|
+
// storage — this is the ONLY write path onto the subscription
|
|
135
|
+
// stream, so encrypting here covers both the event-log payload AND
|
|
136
|
+
// (via projection.ts copying the event fields as-is) the
|
|
137
|
+
// read_subscriptions row with a single call. The subject is the
|
|
138
|
+
// TENANT (tenantOwned) — tenant-destroy's subject-keys stage
|
|
139
|
+
// (eraseSubjectKeys) erases exactly this key, so both copies become
|
|
140
|
+
// genuinely unreadable (#800) once that stage runs, not just
|
|
141
|
+
// "encrypted at rest". No adapter configured = engine off (fields
|
|
142
|
+
// stay plaintext, pre-#724-phase-C behavior) — mirrors how the
|
|
143
|
+
// event-store-executor treats an absent piiKms().
|
|
144
|
+
// ---------------------------------------------------------------
|
|
145
|
+
const piiKms = configuredPiiSubjectKms();
|
|
146
|
+
const encryptedFields = piiKms
|
|
147
|
+
? await encryptPiiFieldValues(
|
|
148
|
+
{
|
|
149
|
+
tenantId,
|
|
150
|
+
providerCustomerId: payload.providerCustomerId,
|
|
151
|
+
providerSubscriptionId: payload.providerSubscriptionId,
|
|
152
|
+
},
|
|
153
|
+
subscriptionEntity,
|
|
154
|
+
SUBSCRIPTION_PII_FIELDS,
|
|
155
|
+
piiKms,
|
|
156
|
+
{ requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
|
|
157
|
+
)
|
|
158
|
+
: {
|
|
159
|
+
providerCustomerId: payload.providerCustomerId,
|
|
160
|
+
providerSubscriptionId: payload.providerSubscriptionId,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------
|
|
164
|
+
// 4. Append event auf den subscription-stream. Inline-projection
|
|
129
165
|
// materialisiert die read_subscriptions-row in derselben TX.
|
|
130
166
|
// ---------------------------------------------------------------
|
|
131
167
|
const eventPayload: SubscriptionEventPayload = {
|
|
132
168
|
providerName: payload.providerName,
|
|
133
|
-
providerCustomerId:
|
|
134
|
-
providerSubscriptionId:
|
|
169
|
+
providerCustomerId: encryptedFields["providerCustomerId"] as string,
|
|
170
|
+
providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
|
|
135
171
|
status: payload.status,
|
|
136
172
|
tier: payload.tier,
|
|
137
173
|
currentPeriodEndIso: payload.currentPeriodEndIso,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { deleteMany, type EntityTableMeta, updateMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
5
|
+
import { subscriptionAggregateId } from "./aggregate-id";
|
|
6
|
+
import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
|
|
7
|
+
import { subscriptionsProjectionTable } from "./projection";
|
|
8
|
+
|
|
9
|
+
// No field-level encryption is wired up for `read_subscriptions` (see
|
|
10
|
+
// entities.ts) — a crypto-shredding retention strategy would need that built
|
|
11
|
+
// first (envelope-encrypt on write, decrypt on every read call site, backfill
|
|
12
|
+
// existing rows). Redaction achieves the same GDPR outcome without it: HGB
|
|
13
|
+
// needs the accounting facts (status/tier/period), not the Stripe/Mollie
|
|
14
|
+
// subject identifiers.
|
|
15
|
+
const REDACTED_PII_VALUE = "[erased]";
|
|
16
|
+
|
|
17
|
+
/** Tenant-destroy hook for billing PII (#800). HGB retains the accounting
|
|
18
|
+
* row but redacts the two provider-subject PII fields; other profiles
|
|
19
|
+
* hard-delete the row. Either way the subscription stream is archived so a
|
|
20
|
+
* future projection rebuild can't resurrect what was erased. */
|
|
21
|
+
export async function subscriptionTenantDestroyHook(ctx: {
|
|
22
|
+
readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
|
|
23
|
+
readonly tenantId: TenantId;
|
|
24
|
+
}): Promise<void> {
|
|
25
|
+
const { profile } = await resolveProfileForTenant({
|
|
26
|
+
db: ctx.db,
|
|
27
|
+
tenantId: ctx.tenantId,
|
|
28
|
+
});
|
|
29
|
+
const aggregateId = subscriptionAggregateId(ctx.tenantId);
|
|
30
|
+
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
31
|
+
await updateMany(
|
|
32
|
+
ctx.db,
|
|
33
|
+
subscriptionsProjectionTable as EntityTableMeta,
|
|
34
|
+
{ providerCustomerId: REDACTED_PII_VALUE, providerSubscriptionId: REDACTED_PII_VALUE },
|
|
35
|
+
{ id: aggregateId },
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
await deleteMany(ctx.db, subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
|
|
39
|
+
}
|
|
40
|
+
await archiveStream(ctx.db, {
|
|
41
|
+
tenantId: ctx.tenantId,
|
|
42
|
+
aggregateId,
|
|
43
|
+
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
44
|
+
archivedBy: "tenant-lifecycle:destroy",
|
|
45
|
+
reason: "tenant_destroy",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
export const capCounterEntity = createEntity({
|
|
41
41
|
table: "read_cap_counters",
|
|
42
42
|
fields: {
|
|
43
|
-
capName: createTextField({ required: true, maxLength: 100 }),
|
|
43
|
+
capName: createTextField({ required: true, maxLength: 100, sortable: true, searchable: true }),
|
|
44
44
|
value: createNumberField({ required: true, default: 0 }),
|
|
45
45
|
periodStart: createTimestampField({ required: true }),
|
|
46
46
|
lastSoftWarnedAt: createTimestampField(),
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
rollingIncrementedSchema,
|
|
61
61
|
} from "./handlers/increment-rolling.write";
|
|
62
62
|
import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
|
|
63
|
+
import { CAP_COUNTER_I18N } from "./i18n";
|
|
63
64
|
|
|
64
65
|
const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
|
|
65
66
|
|
|
@@ -101,13 +102,20 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
101
102
|
type: "entityList",
|
|
102
103
|
entity: "cap-counter",
|
|
103
104
|
columns: ["capName", "value", "periodStart", "lastSoftWarnedAt"],
|
|
104
|
-
|
|
105
|
+
defaultSort: { field: "capName", dir: "asc" },
|
|
106
|
+
searchable: true,
|
|
105
107
|
access: { roles: ["SystemAdmin"] },
|
|
106
108
|
});
|
|
107
109
|
r.nav({
|
|
108
110
|
id: "cap-list",
|
|
109
|
-
label: "
|
|
111
|
+
label: "cap-counter:nav.cap-list",
|
|
110
112
|
screen: "cap-counter:screen:cap-list",
|
|
111
113
|
order: 60,
|
|
112
114
|
});
|
|
115
|
+
r.translations({
|
|
116
|
+
keys: {
|
|
117
|
+
...CAP_COUNTER_I18N,
|
|
118
|
+
"cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
113
121
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const CAP_COUNTER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:cap-list.title": { de: "Nutzungslimits", en: "Usage caps" },
|
|
5
|
+
"cap-counter:entity:cap-counter:field:capName": { de: "Limit", en: "Cap" },
|
|
6
|
+
"cap-counter:entity:cap-counter:field:value": { de: "Wert", en: "Value" },
|
|
7
|
+
"cap-counter:entity:cap-counter:field:periodStart": { de: "Periodenstart", en: "Period start" },
|
|
8
|
+
"cap-counter:entity:cap-counter:field:lastSoftWarnedAt": {
|
|
9
|
+
de: "Letzte Soft-Warnung",
|
|
10
|
+
en: "Last soft warning",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -9,6 +9,7 @@ import { listProfilesQuery } from "./handlers/list-profiles.query";
|
|
|
9
9
|
import { needsProfileQuery } from "./handlers/needs-profile.query";
|
|
10
10
|
import { setProfileWrite } from "./handlers/set-profile.write";
|
|
11
11
|
import { subProcessorsQuery } from "./handlers/sub-processors.query";
|
|
12
|
+
import { COMPLIANCE_PROFILES_I18N } from "./i18n";
|
|
12
13
|
import { tenantComplianceProfileEntity } from "./schema/profile-selection";
|
|
13
14
|
|
|
14
15
|
export {
|
|
@@ -72,6 +73,8 @@ export function createComplianceProfilesFeature(): FeatureDefinition {
|
|
|
72
73
|
order: 50,
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
r.translations({ keys: COMPLIANCE_PROFILES_I18N });
|
|
77
|
+
|
|
75
78
|
return { handlers, queries };
|
|
76
79
|
});
|
|
77
80
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const COMPLIANCE_PROFILES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:profile-picker.title": { de: "Compliance-Profil", en: "Compliance profile" },
|
|
5
|
+
"compliance-profiles:nav.profilePicker": { de: "Compliance", en: "Compliance" },
|
|
6
|
+
};
|
package/src/config/feature.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { resetWrite } from "./handlers/reset.write";
|
|
|
21
21
|
import { schemaQuery } from "./handlers/schema.query";
|
|
22
22
|
import { setWrite } from "./handlers/set.write";
|
|
23
23
|
import { valuesQuery } from "./handlers/values.query";
|
|
24
|
+
import { CONFIG_FEATURE_I18N } from "./i18n";
|
|
24
25
|
import type { ConfigResolver } from "./resolver";
|
|
25
26
|
import { configValueEntity } from "./table";
|
|
26
27
|
|
|
@@ -63,6 +64,8 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
63
64
|
// legacy CONFIG_ENCRYPTION_KEY.
|
|
64
65
|
r.job("reencrypt", { trigger: { manual: true } }, reencryptJob);
|
|
65
66
|
|
|
67
|
+
r.translations({ keys: CONFIG_FEATURE_I18N });
|
|
68
|
+
|
|
66
69
|
return { handlers, queries };
|
|
67
70
|
});
|
|
68
71
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
|
+
|
|
3
|
+
/** Server boot keys for the auto-generated Settings-Hub (mirrors web/i18n.ts). */
|
|
4
|
+
export const CONFIG_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
5
|
+
"config.settings.title": { de: "Einstellungen", en: "Settings" },
|
|
6
|
+
"config.settings.system": { de: "Plattform", en: "Platform" },
|
|
7
|
+
"config.settings.tenant": { de: "Organisation", en: "Organization" },
|
|
8
|
+
"config.settings.user": { de: "Persönlich", en: "Personal" },
|
|
9
|
+
};
|
package/src/delivery/feature.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { deliveryAttemptSchema } from "./events";
|
|
|
10
10
|
import { logQuery } from "./handlers/log.query";
|
|
11
11
|
import { preferencesQuery } from "./handlers/preferences.query";
|
|
12
12
|
import { setPreferenceWrite } from "./handlers/set-preference.write";
|
|
13
|
+
import { DELIVERY_I18N } from "./i18n";
|
|
13
14
|
import { deliveryRenderJob, deliverySendJob } from "./jobs";
|
|
14
15
|
import {
|
|
15
16
|
deliveryAttemptsTable,
|
|
@@ -134,6 +135,8 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
134
135
|
order: 40,
|
|
135
136
|
});
|
|
136
137
|
|
|
138
|
+
r.translations({ keys: DELIVERY_I18N });
|
|
139
|
+
|
|
137
140
|
return { handlers, queries };
|
|
138
141
|
});
|
|
139
142
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:delivery-log.title": { de: "Delivery-Log", en: "Delivery log" },
|
|
5
|
+
"delivery:nav.deliveryLog": { de: "Zustellungen", en: "Delivery" },
|
|
6
|
+
};
|
|
@@ -4,6 +4,7 @@ import { featureToggleSetSchema } from "./events";
|
|
|
4
4
|
import { listQuery } from "./handlers/list.query";
|
|
5
5
|
import { registeredQuery } from "./handlers/registered.query";
|
|
6
6
|
import { createSetWriteHandler } from "./handlers/set.write";
|
|
7
|
+
import { FEATURE_TOGGLES_I18N } from "./i18n";
|
|
7
8
|
import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
|
|
8
9
|
|
|
9
10
|
// IMPORTANT: feature-toggles itself is NOT r.toggleable. Making it
|
|
@@ -75,6 +76,8 @@ export function createFeatureTogglesFeature(
|
|
|
75
76
|
order: 20,
|
|
76
77
|
});
|
|
77
78
|
|
|
79
|
+
r.translations({ keys: FEATURE_TOGGLES_I18N });
|
|
80
|
+
|
|
78
81
|
// toggle-cache-sync — multi-instance snapshot propagation. Every
|
|
79
82
|
// API/worker instance runs its own dispatcher cursor on this MSP
|
|
80
83
|
// (delivery: "per-instance") and converges its in-memory snapshot on
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const FEATURE_TOGGLES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:toggle-admin.title": { de: "Feature-Toggles", en: "Feature toggles" },
|
|
5
|
+
"feature-toggles:nav.toggleAdmin": { de: "Feature-Toggles", en: "Feature toggles" },
|
|
6
|
+
};
|
package/src/jobs/i18n.ts
CHANGED
|
@@ -13,6 +13,10 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
13
13
|
"jobs.runs.open": { de: "Details", en: "Details" },
|
|
14
14
|
"jobs.runs.filter.status": { de: "Status", en: "Status" },
|
|
15
15
|
"jobs.runs.filter.all": { de: "Alle", en: "All" },
|
|
16
|
+
"jobs.runs.filter.completed": { de: "Abgeschlossen", en: "Completed" },
|
|
17
|
+
"jobs.runs.filter.failed": { de: "Fehlgeschlagen", en: "Failed" },
|
|
18
|
+
"jobs.runs.filter.running": { de: "Läuft", en: "Running" },
|
|
19
|
+
"jobs.runs.filter.queued": { de: "Wartend", en: "Queued" },
|
|
16
20
|
"jobs.runs.col.job": { de: "Job", en: "Job" },
|
|
17
21
|
"jobs.runs.col.status": { de: "Status", en: "Status" },
|
|
18
22
|
"jobs.runs.col.started": { de: "Gestartet", en: "Started" },
|
|
@@ -24,11 +24,11 @@ type State =
|
|
|
24
24
|
| { readonly kind: "ready"; readonly rows: readonly JobRunRow[] };
|
|
25
25
|
|
|
26
26
|
const STATUS_FILTER_OPTIONS = [
|
|
27
|
-
{ value: "",
|
|
28
|
-
{ value: "completed",
|
|
29
|
-
{ value: "failed",
|
|
30
|
-
{ value: "running",
|
|
31
|
-
{ value: "queued",
|
|
27
|
+
{ value: "", labelKey: "jobs.runs.filter.all" },
|
|
28
|
+
{ value: "completed", labelKey: "jobs.runs.filter.completed" },
|
|
29
|
+
{ value: "failed", labelKey: "jobs.runs.filter.failed" },
|
|
30
|
+
{ value: "running", labelKey: "jobs.runs.filter.running" },
|
|
31
|
+
{ value: "queued", labelKey: "jobs.runs.filter.queued" },
|
|
32
32
|
] as const;
|
|
33
33
|
|
|
34
34
|
export function JobRunsScreen(): ReactNode {
|
|
@@ -41,7 +41,7 @@ export function JobRunsScreen(): ReactNode {
|
|
|
41
41
|
|
|
42
42
|
const filterOptions = STATUS_FILTER_OPTIONS.map((opt) => ({
|
|
43
43
|
value: opt.value,
|
|
44
|
-
label:
|
|
44
|
+
label: t(opt.labelKey),
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
47
|
const refresh = useCallback(async (): Promise<void> => {
|
|
@@ -98,13 +98,13 @@ export function JobRunsScreen(): ReactNode {
|
|
|
98
98
|
<DataTable
|
|
99
99
|
testId="job-runs-table"
|
|
100
100
|
columns={[
|
|
101
|
-
{ field: "job", label: t("jobs.runs.col.job"), type: "string", sortable:
|
|
102
|
-
{ field: "status", label: t("jobs.runs.col.status"), type: "string", sortable:
|
|
101
|
+
{ field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: true },
|
|
102
|
+
{ field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: true },
|
|
103
103
|
{
|
|
104
104
|
field: "started",
|
|
105
105
|
label: t("jobs.runs.col.started"),
|
|
106
106
|
type: "string",
|
|
107
|
-
sortable:
|
|
107
|
+
sortable: true,
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
field: "duration",
|
|
@@ -24,6 +24,11 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
24
24
|
"managed-pages:entity:page:field:lang": { de: "Sprache", en: "Language" },
|
|
25
25
|
"managed-pages:entity:page:field:title": { de: "Titel", en: "Title" },
|
|
26
26
|
"managed-pages:entity:page:field:published": { de: "Veröffentlicht", en: "Published" },
|
|
27
|
+
"managed-pages:entity:page:field:published:option:true": {
|
|
28
|
+
de: "Veröffentlicht",
|
|
29
|
+
en: "Published",
|
|
30
|
+
},
|
|
31
|
+
"managed-pages:entity:page:field:published:option:false": { de: "Entwurf", en: "Draft" },
|
|
27
32
|
"managed-pages:entity:page:field:description": { de: "Beschreibung", en: "Description" },
|
|
28
33
|
"managed-pages:entity:page:field:ogImage": { de: "OG-Bild", en: "OG image" },
|
|
29
34
|
"managed-pages:entity:page:field:body": { de: "Inhalt", en: "Content" },
|
|
@@ -24,7 +24,11 @@ export const pageListScreen: EntityListScreenDefinition = {
|
|
|
24
24
|
"title",
|
|
25
25
|
{
|
|
26
26
|
field: "published",
|
|
27
|
-
renderer: {
|
|
27
|
+
renderer: {
|
|
28
|
+
format: "boolean",
|
|
29
|
+
trueLabel: "managed-pages:entity:page:field:published:option:true",
|
|
30
|
+
falseLabel: "managed-pages:entity:page:field:published:option:false",
|
|
31
|
+
},
|
|
28
32
|
},
|
|
29
33
|
],
|
|
30
34
|
defaultSort: { field: "slug", dir: "asc" },
|
|
@@ -5,6 +5,7 @@ import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
|
|
|
5
5
|
import { createPatWrite } from "./handlers/create.write";
|
|
6
6
|
import { listPatQuery } from "./handlers/list.query";
|
|
7
7
|
import { revokePatWrite } from "./handlers/revoke.write";
|
|
8
|
+
import { PAT_FEATURE_I18N } from "./i18n";
|
|
8
9
|
import { apiTokenEntity } from "./schema/api-token";
|
|
9
10
|
import type { PatScopeConfig } from "./scopes";
|
|
10
11
|
|
|
@@ -75,6 +76,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
75
76
|
renderer: { react: { __component: "PatTokensScreen" } },
|
|
76
77
|
access: { openToAll: true },
|
|
77
78
|
});
|
|
79
|
+
r.translations({ keys: PAT_FEATURE_I18N });
|
|
78
80
|
|
|
79
81
|
// scopes + rateLimit flow into feature.exports so run-prod-app builds the
|
|
80
82
|
// resolver + limiter from the same declaration — single source of truth.
|
|
@@ -41,7 +41,11 @@ function isoDate(value: string | null): string | null {
|
|
|
41
41
|
return typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : null;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export function PatTokensScreen(
|
|
44
|
+
export function PatTokensScreen({
|
|
45
|
+
embedded = false,
|
|
46
|
+
}: {
|
|
47
|
+
readonly embedded?: boolean;
|
|
48
|
+
} = {}): ReactNode {
|
|
45
49
|
const t = useTranslation();
|
|
46
50
|
const { Form, Field, Input, Button, Banner, Card, Heading } = usePrimitives();
|
|
47
51
|
const dispatcher = useDispatcher();
|
|
@@ -130,7 +134,7 @@ export function PatTokensScreen(): ReactNode {
|
|
|
130
134
|
};
|
|
131
135
|
|
|
132
136
|
return (
|
|
133
|
-
<div className="flex max-w-3xl flex-col gap-6 p-6">
|
|
137
|
+
<div className={embedded ? "flex flex-col gap-6" : "flex max-w-3xl flex-col gap-6 p-6"}>
|
|
134
138
|
<Heading>{t("pat.title")}</Heading>
|
|
135
139
|
|
|
136
140
|
{minted && (
|
|
@@ -232,7 +236,7 @@ export function PatTokensScreen(): ReactNode {
|
|
|
232
236
|
<div className="flex items-center justify-between gap-3">
|
|
233
237
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
234
238
|
<span className="truncate text-sm font-semibold">{row.name}</span>
|
|
235
|
-
<span className="
|
|
239
|
+
<span className="whitespace-normal text-xs text-muted-foreground">
|
|
236
240
|
{revoked ? t("pat.list.revoked") : meta(row)}
|
|
237
241
|
</span>
|
|
238
242
|
</div>
|
|
@@ -39,6 +39,10 @@ import type {
|
|
|
39
39
|
Subscription as MollieSubscription,
|
|
40
40
|
} from "@mollie/api-client";
|
|
41
41
|
import { Hono } from "hono";
|
|
42
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
43
|
+
import { createConfigFeature } from "../../config";
|
|
44
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
45
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
42
46
|
import { createSubscriptionMollieFeature } from "../feature";
|
|
43
47
|
import type { MollieClientShape } from "../verify-webhook";
|
|
44
48
|
|
|
@@ -133,7 +137,14 @@ beforeAll(async () => {
|
|
|
133
137
|
});
|
|
134
138
|
|
|
135
139
|
stack = await setupTestStack({
|
|
136
|
-
features: [
|
|
140
|
+
features: [
|
|
141
|
+
createConfigFeature(),
|
|
142
|
+
createTenantFeature(),
|
|
143
|
+
createComplianceProfilesFeature(),
|
|
144
|
+
createTenantLifecycleFeature(),
|
|
145
|
+
billingFoundationFeature,
|
|
146
|
+
mollieFeature,
|
|
147
|
+
],
|
|
137
148
|
});
|
|
138
149
|
db = stack.db;
|
|
139
150
|
// subscriptionsProjectionTable wird von setupTestStack automatisch
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
35
35
|
import { Hono } from "hono";
|
|
36
36
|
import Stripe from "stripe";
|
|
37
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
37
38
|
import { configValuesTable, createConfigFeature } from "../../config";
|
|
38
39
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
39
40
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -43,6 +44,8 @@ import {
|
|
|
43
44
|
type SecretsContext,
|
|
44
45
|
tenantSecretsTable,
|
|
45
46
|
} from "../../secrets";
|
|
47
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
48
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
46
49
|
import { createSubscriptionStripeFeature } from "../feature";
|
|
47
50
|
|
|
48
51
|
// Qualified-names der runtime-keys (drift-pin: müssen 1:1 dem entsprechen,
|
|
@@ -93,6 +96,9 @@ beforeAll(async () => {
|
|
|
93
96
|
features: [
|
|
94
97
|
createConfigFeature(),
|
|
95
98
|
createSecretsFeature(),
|
|
99
|
+
createTenantFeature(),
|
|
100
|
+
createComplianceProfilesFeature(),
|
|
101
|
+
createTenantLifecycleFeature(),
|
|
96
102
|
billingFoundationFeature,
|
|
97
103
|
stripeFeature,
|
|
98
104
|
],
|
|
@@ -483,6 +489,9 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
|
|
|
483
489
|
features: [
|
|
484
490
|
createConfigFeature(),
|
|
485
491
|
createSecretsFeature(),
|
|
492
|
+
createTenantFeature(),
|
|
493
|
+
createComplianceProfilesFeature(),
|
|
494
|
+
createTenantLifecycleFeature(),
|
|
486
495
|
billingFoundationFeature,
|
|
487
496
|
stripeFeature,
|
|
488
497
|
],
|
|
@@ -172,6 +172,8 @@ export function createSubscriptionStripeFeature(
|
|
|
172
172
|
de: "Stripe Billing live",
|
|
173
173
|
en: "Stripe Billing Live",
|
|
174
174
|
},
|
|
175
|
+
"subscription-stripe.settings": { de: "Stripe", en: "Stripe" },
|
|
176
|
+
"screen:subscription-stripe-system.title": { de: "Stripe", en: "Stripe" },
|
|
175
177
|
},
|
|
176
178
|
});
|
|
177
179
|
|
package/src/tags/feature.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { createCreateTagHandler } from "./handlers/create-tag.write";
|
|
|
29
29
|
import { createDeleteTagHandler } from "./handlers/delete-tag.write";
|
|
30
30
|
import { createRemoveTagHandler } from "./handlers/remove-tag.write";
|
|
31
31
|
import { createUpdateTagHandler } from "./handlers/update-tag.write";
|
|
32
|
+
import { TAGS_FEATURE_I18N } from "./i18n";
|
|
32
33
|
|
|
33
34
|
// Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
|
|
34
35
|
// dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
|
|
@@ -76,6 +77,7 @@ function registerTags(
|
|
|
76
77
|
renderer: { react: { __component: "TagsScreen" } },
|
|
77
78
|
access,
|
|
78
79
|
});
|
|
80
|
+
r.translations({ keys: TAGS_FEATURE_I18N });
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
package/src/tags/i18n.ts
ADDED
|
@@ -5,6 +5,7 @@ import { AuthHandlers } from "../../auth-email-password/constants";
|
|
|
5
5
|
import { createConfigFeature } from "../../config/feature";
|
|
6
6
|
import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
|
|
7
7
|
import { createTenantFeature } from "../feature";
|
|
8
|
+
import { defaultTranslations } from "../web/i18n";
|
|
8
9
|
|
|
9
10
|
describe("tenant members screen + handler access alignment", () => {
|
|
10
11
|
const features = [createConfigFeature(), createTenantFeature()];
|
|
@@ -34,6 +35,12 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
34
35
|
void TenantQueries;
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
test("members nav label uses tenantClient i18n key (not feature:nav.* alias)", () => {
|
|
39
|
+
const nav = createTenantFeature().navs["members"];
|
|
40
|
+
expect(nav?.label).toBe("tenant.nav.members");
|
|
41
|
+
expect(defaultTranslations["de"]?.["tenant.nav.members"]).toBe("Team");
|
|
42
|
+
});
|
|
43
|
+
|
|
37
44
|
test("updateMemberRoles stays SystemAdmin/system-only (not on members screen)", () => {
|
|
38
45
|
const tenant = createTenantFeature();
|
|
39
46
|
expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
|
|
@@ -197,12 +197,12 @@ describe("TenantAdmin can use members-admin HTTP surface", () => {
|
|
|
197
197
|
{ email: "pending@example.com", role: "Editor" },
|
|
198
198
|
tenantAdminA(),
|
|
199
199
|
);
|
|
200
|
-
const members = await stack.http.queryOk<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
tenantAdminA(),
|
|
204
|
-
);
|
|
200
|
+
const members = await stack.http.queryOk<
|
|
201
|
+
readonly { userId: string; email: string | null; displayName: string | null }[]
|
|
202
|
+
>(TenantQueries.members, {}, tenantAdminA());
|
|
205
203
|
expect(members.some((m) => m.userId === tenantAdminAId)).toBe(true);
|
|
204
|
+
const self = members.find((m) => m.userId === tenantAdminAId);
|
|
205
|
+
expect(self?.email).toBe("admin-a@example.com");
|
|
206
206
|
const invitations = await stack.http.queryOk<readonly { email: string }[]>(
|
|
207
207
|
TenantQueries.invitations,
|
|
208
208
|
{},
|
|
@@ -211,6 +211,21 @@ describe("TenantAdmin can use members-admin HTTP surface", () => {
|
|
|
211
211
|
expect(invitations).toHaveLength(1);
|
|
212
212
|
expect(invitations[0]?.email).toBe("pending@example.com");
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
test("members email is null when user row is missing", async () => {
|
|
216
|
+
const orphanUserId = "00000000-0000-4000-8000-00000000dead";
|
|
217
|
+
await seedTenantMembership(stack.db, {
|
|
218
|
+
userId: orphanUserId,
|
|
219
|
+
tenantId: TENANT_A_ID,
|
|
220
|
+
roles: ["User"],
|
|
221
|
+
});
|
|
222
|
+
const members = await stack.http.queryOk<readonly { userId: string; email: string | null }[]>(
|
|
223
|
+
TenantQueries.members,
|
|
224
|
+
{},
|
|
225
|
+
tenantAdminA(),
|
|
226
|
+
);
|
|
227
|
+
expect(members.find((m) => m.userId === orphanUserId)?.email).toBeNull();
|
|
228
|
+
});
|
|
214
229
|
});
|
|
215
230
|
|
|
216
231
|
describe("regular User is denied members-admin surface", () => {
|