@cosmicdrift/kumiko-samples 0.351.0 → 0.353.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 +1 -1
- package/packages/bundled-features/package.json +2 -2
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +6 -1
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/ledger/entity.ts +4 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/tenant/changes.json +7 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +13 -1
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VersionConflictError,
|
|
15
15
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
16
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
17
|
+
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
17
18
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
18
19
|
import { tenantEntity, tenantTable } from "../tenant/index.js";
|
|
19
20
|
import {
|
|
@@ -222,6 +223,7 @@ export async function runNextDestructionStage(args: {
|
|
|
222
223
|
readonly tenantId: TenantId;
|
|
223
224
|
readonly log?: (message: string) => void;
|
|
224
225
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
226
|
+
readonly searchAdapter?: SearchAdapter;
|
|
225
227
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
226
228
|
readonly actor?: string;
|
|
227
229
|
}): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
|
|
@@ -246,6 +248,7 @@ export async function runNextDestructionStage(args: {
|
|
|
246
248
|
tenantId: args.tenantId,
|
|
247
249
|
log: args.log,
|
|
248
250
|
fileProviderResolver: args.fileProviderResolver,
|
|
251
|
+
searchAdapter: args.searchAdapter,
|
|
249
252
|
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
250
253
|
actor: args.actor,
|
|
251
254
|
};
|
|
@@ -334,6 +337,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
334
337
|
readonly now?: Temporal.Instant;
|
|
335
338
|
readonly log?: (message: string) => void;
|
|
336
339
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
340
|
+
readonly searchAdapter?: SearchAdapter;
|
|
337
341
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
338
342
|
readonly actor?: string;
|
|
339
343
|
}): Promise<{ readonly triggered: number; readonly advanced: number }> {
|
|
@@ -380,6 +384,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
380
384
|
tenantId: row.id as TenantId,
|
|
381
385
|
log: args.log,
|
|
382
386
|
fileProviderResolver: args.fileProviderResolver,
|
|
387
|
+
searchAdapter: args.searchAdapter,
|
|
383
388
|
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
384
389
|
actor: args.actor,
|
|
385
390
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
configuredPiiSubjectKms,
|
|
4
|
+
type SubjectId,
|
|
5
|
+
subjectIdToKey,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
7
|
import {
|
|
4
8
|
createEventStoreExecutor,
|
|
5
9
|
createTenantDb,
|
|
@@ -26,6 +30,10 @@ import {
|
|
|
26
30
|
createEscapeHatchReporter,
|
|
27
31
|
UNATTRIBUTED_ACTOR,
|
|
28
32
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
33
|
+
import {
|
|
34
|
+
purgeSearchDocumentsForSubject,
|
|
35
|
+
type SearchAdapter,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/search";
|
|
29
37
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
30
38
|
import {
|
|
31
39
|
tenantEntity,
|
|
@@ -45,6 +53,9 @@ export type DestructionStageCtx = {
|
|
|
45
53
|
// "destroyTenant" hooks) — undefined when no file-provider is wired,
|
|
46
54
|
// which those hooks must treat as "nothing to clean up", not an error.
|
|
47
55
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
56
|
+
// Derived search docs of the tenant record (its name) live in the writer's
|
|
57
|
+
// index, not the destroyed tenant's, so the subject-keys stage purges them.
|
|
58
|
+
readonly searchAdapter?: SearchAdapter;
|
|
48
59
|
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
49
60
|
// systemUser.id); runTenantDataHooks uses them to attribute+audit any
|
|
50
61
|
// EXT_TENANT_DATA usage's declared escapeHatch.
|
|
@@ -123,8 +134,10 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
|
|
|
123
134
|
const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
|
|
124
135
|
tenantId: ctx.tenantId,
|
|
125
136
|
});
|
|
137
|
+
const tenantRecordSubject: SubjectId = { kind: "record", entity: "tenant", id: ctx.tenantId };
|
|
126
138
|
const subjects: SubjectId[] = [
|
|
127
139
|
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
140
|
+
tenantRecordSubject,
|
|
128
141
|
...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
|
|
129
142
|
];
|
|
130
143
|
for (const subject of subjects) {
|
|
@@ -133,6 +146,15 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
|
|
|
133
146
|
eraseReason: "tenant-destroy stage subject-keys",
|
|
134
147
|
});
|
|
135
148
|
}
|
|
149
|
+
if (ctx.searchAdapter) {
|
|
150
|
+
await purgeSearchDocumentsForSubject(
|
|
151
|
+
ctx.db,
|
|
152
|
+
ctx.registry.features,
|
|
153
|
+
ctx.searchAdapter,
|
|
154
|
+
subjectIdToKey(tenantRecordSubject),
|
|
155
|
+
tenantRecordSubject,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "createTierOptionGate builds the availability rows and gates select options by tier on write",
|
|
6
|
+
"detail": "withTierOptionGate rejects a disallowed option with the lowest tier that allows it and lets an unchanged stored value pass on update."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.278.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -29,6 +29,13 @@ export {
|
|
|
29
29
|
createTierEngineFeature,
|
|
30
30
|
tierEngineFeature,
|
|
31
31
|
} from "./feature.js";
|
|
32
|
+
export {
|
|
33
|
+
createTierOptionGate,
|
|
34
|
+
type TierOptionAvailability,
|
|
35
|
+
type TierOptionGate,
|
|
36
|
+
type TierOptionGateDeps,
|
|
37
|
+
type TierOptionSpec,
|
|
38
|
+
} from "./tier-option-gate.js";
|
|
32
39
|
export {
|
|
33
40
|
createTierResolver,
|
|
34
41
|
type TierResolver,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Tier-gated options of a select field: one spec drives the availability query the
|
|
2
|
+
// renderer shows (disabled option + "from <tier>" hint) and the write-path check that
|
|
3
|
+
// actually enforces it, so the two cannot drift apart.
|
|
4
|
+
|
|
5
|
+
import type { EntityTableMeta, SchemaTable, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
UnprocessableError,
|
|
9
|
+
type WriteFailure,
|
|
10
|
+
writeFailure,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
|
|
13
|
+
import {
|
|
14
|
+
type CapGuardContext,
|
|
15
|
+
type CapLimitContext,
|
|
16
|
+
readPayloadField,
|
|
17
|
+
} from "../cap-counter/index.js";
|
|
18
|
+
|
|
19
|
+
export type TierOptionSpec<TCaps> = {
|
|
20
|
+
readonly field: string;
|
|
21
|
+
readonly options: readonly string[];
|
|
22
|
+
readonly isOptionAllowed: (option: string, caps: TCaps) => boolean;
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly i18nKey: string;
|
|
25
|
+
/** Lets an update that resends the unchanged stored value of a no longer allowed
|
|
26
|
+
* option pass (edit forms submit the whole row). Without it the stored value is not looked up. */
|
|
27
|
+
readonly table?: SchemaTable | EntityTableMeta;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TierOptionAvailability = {
|
|
31
|
+
readonly value: string;
|
|
32
|
+
readonly disabled: boolean;
|
|
33
|
+
readonly hint?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TierOptionGateDeps<TTier extends string, TCaps> = {
|
|
37
|
+
/** Ascending: the first tier that allows an option is the one named as required. */
|
|
38
|
+
readonly tierOrder: readonly TTier[];
|
|
39
|
+
readonly capsForTier: (tier: TTier, context: CapLimitContext) => TCaps | Promise<TCaps>;
|
|
40
|
+
readonly resolveTier: (db: TenantDb) => Promise<TTier>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type TierOptionGate<TTier extends string, TCaps> = {
|
|
44
|
+
readonly optionAvailability: (
|
|
45
|
+
db: TenantDb,
|
|
46
|
+
context: CapLimitContext,
|
|
47
|
+
spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
|
|
48
|
+
hintFor: (requiredTier: TTier) => string,
|
|
49
|
+
) => Promise<readonly TierOptionAvailability[]>;
|
|
50
|
+
/** `recordId` is the id of the edited row; with `spec.table` it enables the unchanged-value pass. */
|
|
51
|
+
readonly checkTierOption: (
|
|
52
|
+
ctx: CapGuardContext,
|
|
53
|
+
value: unknown,
|
|
54
|
+
spec: TierOptionSpec<TCaps>,
|
|
55
|
+
recordId?: string,
|
|
56
|
+
) => Promise<WriteFailure | null>;
|
|
57
|
+
readonly withTierOptionGate: (
|
|
58
|
+
handler: WriteHandlerDef,
|
|
59
|
+
spec: TierOptionSpec<TCaps>,
|
|
60
|
+
) => WriteHandlerDef;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function limitContextOf(ctx: CapGuardContext): CapLimitContext {
|
|
64
|
+
return ctx.config ? { config: ctx.config } : {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordIdOf(payload: unknown): string | undefined {
|
|
68
|
+
return isPlainObject(payload) && typeof payload["id"] === "string" ? payload["id"] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createTierOptionGate<TTier extends string, TCaps>(
|
|
72
|
+
deps: TierOptionGateDeps<TTier, TCaps>,
|
|
73
|
+
): TierOptionGate<TTier, TCaps> {
|
|
74
|
+
async function capsPerTier(
|
|
75
|
+
context: CapLimitContext,
|
|
76
|
+
): Promise<readonly { readonly tier: TTier; readonly caps: TCaps }[]> {
|
|
77
|
+
return Promise.all(
|
|
78
|
+
deps.tierOrder.map(async (tier) => ({ tier, caps: await deps.capsForTier(tier, context) })),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function optionAvailability(
|
|
83
|
+
db: TenantDb,
|
|
84
|
+
context: CapLimitContext,
|
|
85
|
+
spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
|
|
86
|
+
hintFor: (requiredTier: TTier) => string,
|
|
87
|
+
): Promise<readonly TierOptionAvailability[]> {
|
|
88
|
+
const currentCaps = await deps.capsForTier(await deps.resolveTier(db), context);
|
|
89
|
+
const tiers = await capsPerTier(context);
|
|
90
|
+
return spec.options.map((value) => {
|
|
91
|
+
if (spec.isOptionAllowed(value, currentCaps)) return { value, disabled: false };
|
|
92
|
+
const required = tiers.find(({ caps }) => spec.isOptionAllowed(value, caps));
|
|
93
|
+
return required === undefined
|
|
94
|
+
? { value, disabled: true }
|
|
95
|
+
: { value, disabled: true, hint: hintFor(required.tier) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function storedValueMatches(
|
|
100
|
+
ctx: CapGuardContext,
|
|
101
|
+
spec: TierOptionSpec<TCaps>,
|
|
102
|
+
value: string,
|
|
103
|
+
recordId: string | undefined,
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
if (spec.table === undefined || recordId === undefined) return false;
|
|
106
|
+
const stored = await ctx.db.fetchOne<Record<string, unknown>>(spec.table, { id: recordId });
|
|
107
|
+
return stored?.[spec.field] === value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function checkTierOption(
|
|
111
|
+
ctx: CapGuardContext,
|
|
112
|
+
value: unknown,
|
|
113
|
+
spec: TierOptionSpec<TCaps>,
|
|
114
|
+
recordId?: string,
|
|
115
|
+
): Promise<WriteFailure | null> {
|
|
116
|
+
// skip: an absent or non-string value is the schema's business, not a tier violation.
|
|
117
|
+
if (typeof value !== "string") return null;
|
|
118
|
+
const context = limitContextOf(ctx);
|
|
119
|
+
const currentCaps = await deps.capsForTier(await deps.resolveTier(ctx.db), context);
|
|
120
|
+
if (spec.isOptionAllowed(value, currentCaps)) return null;
|
|
121
|
+
if (await storedValueMatches(ctx, spec, value, recordId)) return null;
|
|
122
|
+
const required = (await capsPerTier(context)).find(({ caps }) =>
|
|
123
|
+
spec.isOptionAllowed(value, caps),
|
|
124
|
+
);
|
|
125
|
+
return writeFailure(
|
|
126
|
+
new UnprocessableError(spec.code, {
|
|
127
|
+
i18nKey: spec.i18nKey,
|
|
128
|
+
details: { field: spec.field, value, requiredTier: required?.tier },
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function withTierOptionGate(
|
|
134
|
+
handler: WriteHandlerDef,
|
|
135
|
+
spec: TierOptionSpec<TCaps>,
|
|
136
|
+
): WriteHandlerDef {
|
|
137
|
+
return {
|
|
138
|
+
...handler,
|
|
139
|
+
handler: async (event, ctx) => {
|
|
140
|
+
const value = readPayloadField(event.payload, spec.field);
|
|
141
|
+
const failure = await checkTierOption(ctx, value, spec, recordIdOf(event.payload));
|
|
142
|
+
return failure ?? handler.handler(event, ctx);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { optionAvailability, checkTierOption, withTierOptionGate };
|
|
148
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
9
|
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
|
|
10
10
|
// kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
|
|
11
|
-
import { tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
|
|
11
|
+
import { decryptTenantNames, tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
|
|
12
12
|
import { userEntity } from "../schema/user.js";
|
|
13
13
|
|
|
14
14
|
const baseList = defineEntityListHandler("user", userEntity, {
|
|
@@ -82,7 +82,10 @@ export async function attachTenantLabels(
|
|
|
82
82
|
];
|
|
83
83
|
const tenants =
|
|
84
84
|
tenantIds.length > 0
|
|
85
|
-
? await
|
|
85
|
+
? await decryptTenantNames(
|
|
86
|
+
await selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } }),
|
|
87
|
+
"user:list",
|
|
88
|
+
)
|
|
86
89
|
: [];
|
|
87
90
|
const byUser = labelsByUserId(memberships, tenantLabelById(tenants));
|
|
88
91
|
return rows.map((row) => {
|
|
@@ -24,6 +24,13 @@ export function validateGdprHookCompleteness(features: readonly FeatureDefinitio
|
|
|
24
24
|
if (usage.extensionName !== EXT_USER_DATA) continue;
|
|
25
25
|
const hasExport = typeof usage.options?.["export"] === "function";
|
|
26
26
|
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
27
|
+
// The compile-time "at least one hook" type is bypassed by string-typed
|
|
28
|
+
// extension names and casts; without this an empty bag registers nothing.
|
|
29
|
+
if (!hasExport && !hasDelete) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[kumiko:boot] Feature "${feature.name}" registers EXT_USER_DATA for entity "${usage.entityName}" with neither an export nor a delete hook — the entity silently stays out of the Art.15/17/20 pipeline. Register at least one hook.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
27
34
|
if (hasExport && !hasDelete) {
|
|
28
35
|
throw new Error(
|
|
29
36
|
`[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
|
|
@@ -38,7 +38,7 @@ export function redeemDeletionToken(args: {
|
|
|
38
38
|
readonly secret: string | undefined;
|
|
39
39
|
readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
|
|
40
40
|
// Spends the anchor AND performs the actual lifecycle transition in one
|
|
41
|
-
// atomic step
|
|
41
|
+
// atomic step — the caller (confirm-deletion-by-token) folds the
|
|
42
42
|
// Active→DeletionRequested write itself in here via `updateUserLifecycle`'s
|
|
43
43
|
// `expect: { status: Active, pendingDeletionRequestId }`, so a write issued
|
|
44
44
|
// after `ok: true` can't lose its work to a crash while the grant is
|
package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts
CHANGED
|
@@ -37,7 +37,7 @@ async function readPendingDeletionRequestId(
|
|
|
37
37
|
|
|
38
38
|
// Anonymous apex flow step 2: verify-link target. Verifies the HMAC token,
|
|
39
39
|
// extracts the userId, and flips the grace period through the shared logic —
|
|
40
|
-
// in ONE atomic step with the anchor spend
|
|
40
|
+
// in ONE atomic step with the anchor spend: commitDeletion carries
|
|
41
41
|
// the actual grace-period transition, so a crash after the spend can no
|
|
42
42
|
// longer lose the write step.
|
|
43
43
|
//
|
|
@@ -47,7 +47,7 @@ async function readPendingDeletionRequestId(
|
|
|
47
47
|
// a cancel-deletion (status → Active, pendingDeletionRequestId → null), a
|
|
48
48
|
// replayed token fails against the nulled/renewed requestId — no re-arm.
|
|
49
49
|
//
|
|
50
|
-
// Concurrency
|
|
50
|
+
// Concurrency: two simultaneous confirms of the same token both read
|
|
51
51
|
// the same requestId and both verify the HMAC — commitDeletion spends the
|
|
52
52
|
// anchor AND writes the transition atomically (expect: status===Active &&
|
|
53
53
|
// pendingDeletionRequestId===requestId), so exactly one wins. The loser gets
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
appendEventInTenantDb,
|
|
4
|
+
getStreamVersionInTenantDb,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
6
|
+
import {
|
|
7
|
+
DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
8
|
+
deliveryAttemptsTable,
|
|
9
|
+
} from "../../delivery/index.js";
|
|
3
10
|
import { featureMounted } from "./feature-mounted.js";
|
|
4
11
|
|
|
5
12
|
// userData-Hooks for delivery's attempt log (deferred from #797, closed by
|
|
@@ -28,9 +35,32 @@ export const deliveryAttemptExportHook: UserDataExportHook = async (ctx) => {
|
|
|
28
35
|
};
|
|
29
36
|
};
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
38
|
+
// Both strategies erase the address. The DEK shredding of a forget covers KMS deployments; the
|
|
39
|
+
// erase event also covers plaintext mode, and unlike a read-side UPDATE it survives a projection
|
|
40
|
+
// rebuild. Rows without an address are skipped, which makes a second run a no-op.
|
|
41
|
+
export const deliveryAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
42
|
+
// skip: delivery not mounted — its table doesn't exist, nothing to erase.
|
|
43
|
+
if (!featureMounted(ctx, "delivery")) return;
|
|
44
|
+
const rows = await ctx.db.selectMany<{ id: string; recipientAddress: string | null }>(
|
|
45
|
+
deliveryAttemptsTable,
|
|
46
|
+
{
|
|
47
|
+
tenantId: ctx.tenantId,
|
|
48
|
+
recipientId: ctx.userId,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (row.recipientAddress === null) continue;
|
|
53
|
+
await appendEventInTenantDb(
|
|
54
|
+
ctx.db,
|
|
55
|
+
{
|
|
56
|
+
aggregateType: "deliveryAttempt",
|
|
57
|
+
aggregateId: row.id,
|
|
58
|
+
expectedVersion: await getStreamVersionInTenantDb(ctx.db, row.id),
|
|
59
|
+
type: DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
60
|
+
payload: {},
|
|
61
|
+
metadata: { userId: "system" },
|
|
62
|
+
},
|
|
63
|
+
{ registry: ctx.registry },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
36
66
|
};
|
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
24
24
|
import { workflowRunAggregateId } from "./aggregate-id.js";
|
|
25
25
|
import { registerEventWakeup } from "./event-subscriber.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
startAndRunWorkflow,
|
|
28
|
+
type WorkflowRunFailedPayload,
|
|
29
|
+
WorkflowSuspensionUnsupportedError,
|
|
30
|
+
} from "./runner.js";
|
|
27
31
|
import { registerWorkflow } from "./workflow-registry.js";
|
|
28
32
|
|
|
29
33
|
const log = createFallbackLogger("workflow-runner");
|
|
@@ -98,15 +102,19 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
98
102
|
handlerCtx: ctx as never,
|
|
99
103
|
});
|
|
100
104
|
} catch (error) {
|
|
105
|
+
// Only the suspension error knows its step; a throw inside a step
|
|
106
|
+
// carries no index, so it still reports 0.
|
|
107
|
+
const stepIndex =
|
|
108
|
+
error instanceof WorkflowSuspensionUnsupportedError ? error.stepIndex : 0;
|
|
101
109
|
log.warn("workflow run failed", {
|
|
102
110
|
runId,
|
|
103
111
|
workflowName: workflow.name,
|
|
104
|
-
stepIndex
|
|
112
|
+
stepIndex,
|
|
105
113
|
error: String(error),
|
|
106
114
|
});
|
|
107
115
|
const failedPayload: WorkflowRunFailedPayload = {
|
|
108
116
|
workflowName: workflow.name,
|
|
109
|
-
stepIndex
|
|
117
|
+
stepIndex,
|
|
110
118
|
error: describeWorkflowStepError(error),
|
|
111
119
|
};
|
|
112
120
|
await ctx.unsafeAppendEvent({
|
|
@@ -92,6 +92,11 @@ function checkQ7Fingerprint(
|
|
|
92
92
|
storedFingerprint: string | null,
|
|
93
93
|
): WorkflowRunFailedPayload | null {
|
|
94
94
|
const currentFingerprint = computeDefinitionFingerprint(workflow);
|
|
95
|
+
if (storedFingerprint === null) {
|
|
96
|
+
log.warn(
|
|
97
|
+
`Run ${runId} of workflow "${workflowName}" has no stored definition fingerprint — resuming against the current definition unchecked.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
95
100
|
const fingerprintChanged = storedFingerprint !== null && currentFingerprint !== storedFingerprint;
|
|
96
101
|
if (!fingerprintChanged) {
|
|
97
102
|
return null;
|
|
@@ -306,7 +306,7 @@ export const ASSET_TEMPLATES: ReadonlyArray<AssetTemplate> = [
|
|
|
306
306
|
// treffen ohne Sonderfall. Wird nur für status==="lent" konsultiert.
|
|
307
307
|
export const OWNERS: ReadonlyArray<string> = [
|
|
308
308
|
"",
|
|
309
|
-
"
|
|
309
|
+
"Alex Morgan",
|
|
310
310
|
"Anna Weber",
|
|
311
311
|
"Lars Bergmann",
|
|
312
312
|
"Sina Klein",
|
|
@@ -435,7 +435,7 @@ export const REPORTERS: ReadonlyArray<string> = [
|
|
|
435
435
|
// unzugewiesen damit die Liste nicht zu uniform aussieht.
|
|
436
436
|
export const ASSIGNEES: ReadonlyArray<string> = [
|
|
437
437
|
"",
|
|
438
|
-
"
|
|
438
|
+
"Alex Morgan",
|
|
439
439
|
"IT-Team",
|
|
440
440
|
"Sysadmin",
|
|
441
441
|
"Helpdesk-L1",
|
|
@@ -58,7 +58,7 @@ export function AppShell({
|
|
|
58
58
|
],
|
|
59
59
|
])
|
|
60
60
|
}
|
|
61
|
-
sidebarFooter={<SidebarUser name="
|
|
61
|
+
sidebarFooter={<SidebarUser name="Alex Morgan" email="alex@example.com" />}
|
|
62
62
|
>
|
|
63
63
|
{activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
|
|
64
64
|
</DefaultAppShell>
|
|
@@ -606,20 +606,26 @@ export function Gallery(): ReactNode {
|
|
|
606
606
|
<div className="flex flex-col gap-5 px-6 py-6">
|
|
607
607
|
<div className="flex items-center gap-4">
|
|
608
608
|
<div className="bg-muted text-foreground flex size-16 items-center justify-center rounded-full text-lg font-semibold">
|
|
609
|
-
|
|
609
|
+
AM
|
|
610
610
|
</div>
|
|
611
611
|
<Button variant="secondary">Change avatar</Button>
|
|
612
612
|
</div>
|
|
613
613
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
614
614
|
<Field id="pf-name" label="Full name">
|
|
615
|
-
<Input
|
|
615
|
+
<Input
|
|
616
|
+
kind="text"
|
|
617
|
+
id="pf-name"
|
|
618
|
+
name="pf-name"
|
|
619
|
+
value="Alex Morgan"
|
|
620
|
+
onChange={noop}
|
|
621
|
+
/>
|
|
616
622
|
</Field>
|
|
617
623
|
<Field id="pf-email" label="Email">
|
|
618
624
|
<Input
|
|
619
625
|
kind="text"
|
|
620
626
|
id="pf-email"
|
|
621
627
|
name="pf-email"
|
|
622
|
-
value="
|
|
628
|
+
value="alex@example.com"
|
|
623
629
|
onChange={noop}
|
|
624
630
|
/>
|
|
625
631
|
</Field>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Server-side locale helpers for the dashboard demo queries. Not shared with
|
|
2
|
+
// renderer-web's format.ts: server code must not import renderer-web.
|
|
3
|
+
|
|
4
|
+
import { WIDGETS_I18N } from "./i18n";
|
|
5
|
+
|
|
6
|
+
type WidgetsI18nKey = keyof typeof WIDGETS_I18N;
|
|
7
|
+
type DemoCurrency = "EUR" | "USD";
|
|
8
|
+
|
|
9
|
+
const FALLBACK_LOCALE = "en";
|
|
10
|
+
|
|
11
|
+
// Intl throws RangeError on malformed tags.
|
|
12
|
+
function usableLocale(locale: string): string {
|
|
13
|
+
try {
|
|
14
|
+
return Intl.getCanonicalLocales(locale)[0] ?? FALLBACK_LOCALE;
|
|
15
|
+
} catch {
|
|
16
|
+
return FALLBACK_LOCALE;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The bundle only carries de + en: any "de" tag ("de-AT") picks de, everything else en.
|
|
21
|
+
export function demoText(key: WidgetsI18nKey, locale: string): string {
|
|
22
|
+
const entry = WIDGETS_I18N[key];
|
|
23
|
+
const primarySubtag = usableLocale(locale).split("-")[0];
|
|
24
|
+
return primarySubtag === "de" ? entry.de : entry.en;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function demoMoney(amount: number, currency: DemoCurrency, locale: string): string {
|
|
28
|
+
return new Intl.NumberFormat(usableLocale(locale), {
|
|
29
|
+
style: "currency",
|
|
30
|
+
currency,
|
|
31
|
+
maximumFractionDigits: 0,
|
|
32
|
+
}).format(amount);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function demoPercent(fraction: number, locale: string): string {
|
|
36
|
+
return new Intl.NumberFormat(usableLocale(locale), {
|
|
37
|
+
style: "percent",
|
|
38
|
+
maximumFractionDigits: 0,
|
|
39
|
+
}).format(fraction);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function demoMonthYear(year: number, month: number, locale: string): string {
|
|
43
|
+
return Temporal.PlainDate.from({ year, month, day: 1 }).toLocaleString(usableLocale(locale), {
|
|
44
|
+
month: "short",
|
|
45
|
+
year: "numeric",
|
|
46
|
+
});
|
|
47
|
+
}
|