@cosmicdrift/kumiko-samples 0.352.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 +1 -1
- 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-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/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/public-names.ts +1 -1
- 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/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/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/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/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/showcase/src/features/items/schema/item.ts +0 -1
- 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/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/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -268,7 +268,7 @@ export async function moveTransferGraph(args: {
|
|
|
268
268
|
{ entityName: rootEntityName, rowIds: [rootRowId] },
|
|
269
269
|
];
|
|
270
270
|
|
|
271
|
-
for (let round = 0; round
|
|
271
|
+
for (let round = 0; round <= MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
|
|
272
272
|
// Merged per type so an edge leaving a type runs once per round, not once
|
|
273
273
|
// per edge that reached it.
|
|
274
274
|
const discovered = new Map<string, string[]>();
|
|
@@ -330,25 +330,18 @@ export async function moveTransferGraph(args: {
|
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
// The extra round past the limit is a probe: it only exists to find out
|
|
334
|
+
// whether rows still hang below the last allowed hop. Its moves are rolled
|
|
335
|
+
// back with the transaction when this throws.
|
|
336
|
+
const [strandedEntityName] = discovered.keys();
|
|
337
|
+
if (round === MAX_TRANSFER_DEPTH && strandedEntityName !== undefined) {
|
|
338
|
+
throw new UnprocessableError("transfer_graph_too_deep", {
|
|
339
|
+
i18nKey: "errors.tenantHandover.transferGraphTooDeep",
|
|
340
|
+
details: { entityName: strandedEntityName },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
335
343
|
|
|
336
|
-
|
|
337
|
-
// rounds ran out while rows remain whose type still leads somewhere, the
|
|
338
|
-
// declaration reaches further than the mover does, and returning quietly
|
|
339
|
-
// would leave exactly the orphaned rows #3088 is about.
|
|
340
|
-
//
|
|
341
|
-
// Rounds count hops of both edge kinds, while the boot validator measures
|
|
342
|
-
// reference chains between transferable entities only. A graph that fills the
|
|
343
|
-
// limit with reference hops and then adds a parentRef child therefore boots
|
|
344
|
-
// clean and fails here instead — the residual the validator cannot see, and
|
|
345
|
-
// the reason this check has to exist rather than trusting boot alone.
|
|
346
|
-
const stranded = pending.find(({ entityName }) => (adjacency.get(entityName) ?? []).length > 0);
|
|
347
|
-
if (stranded !== undefined) {
|
|
348
|
-
throw new UnprocessableError("transfer_graph_too_deep", {
|
|
349
|
-
i18nKey: "errors.tenantHandover.transferGraphTooDeep",
|
|
350
|
-
details: { entityName: stranded.entityName },
|
|
351
|
-
});
|
|
344
|
+
pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
|
|
352
345
|
}
|
|
353
346
|
|
|
354
347
|
return movedCounts;
|
|
@@ -141,6 +141,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
141
141
|
now,
|
|
142
142
|
log: (message) => ctx.log?.warn(message),
|
|
143
143
|
fileProviderResolver: ctx._fileProviderResolver,
|
|
144
|
+
searchAdapter: ctx.searchAdapter,
|
|
144
145
|
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
145
146
|
actor: ctx.systemUser.id,
|
|
146
147
|
});
|
|
@@ -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> {
|
|
@@ -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
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { demoMoney, demoMonthYear, demoPercent, demoText } from "./demo-locale";
|
|
8
9
|
import { WIDGETS_I18N } from "./i18n";
|
|
9
10
|
|
|
10
11
|
// Statische Demo-Zeitreihe (48 Punkte à 30 Minuten) — kein Date-API,
|
|
@@ -33,6 +34,13 @@ const INBOX_MESSAGES = Array.from({ length: 18 }, (_, i) => ({
|
|
|
33
34
|
unread: i % 4 === 0,
|
|
34
35
|
}));
|
|
35
36
|
|
|
37
|
+
function openAmount(amount: number, locale: string): string {
|
|
38
|
+
return demoText("widgets:dashboard:demo-amount-open", locale).replace(
|
|
39
|
+
"{amount}",
|
|
40
|
+
demoMoney(amount, "EUR", locale),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
37
45
|
r.screen({ id: "widgets", type: "custom", renderer: { react: { __component: "widgets" } } });
|
|
38
46
|
r.screen({
|
|
@@ -129,11 +137,14 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
129
137
|
r.queryHandler(
|
|
130
138
|
"metrics:portfolio-stat",
|
|
131
139
|
z.object({ region: z.string().optional() }),
|
|
132
|
-
async ({ payload: { region } }) => ({
|
|
133
|
-
value:
|
|
134
|
-
|
|
140
|
+
async ({ payload: { region } }, ctx) => ({
|
|
141
|
+
value:
|
|
142
|
+
region === "us"
|
|
143
|
+
? demoMoney(38120, "USD", ctx.locale)
|
|
144
|
+
: demoMoney(region === "eu" ? 54630 : 92753, "EUR", ctx.locale),
|
|
145
|
+
sub: demoText("widgets:catalog:portfolio-sub", ctx.locale),
|
|
135
146
|
tone: "positive",
|
|
136
|
-
delta:
|
|
147
|
+
delta: demoPercent(0.12, ctx.locale),
|
|
137
148
|
deltaDirection: "up",
|
|
138
149
|
deltaTone: "positive",
|
|
139
150
|
}),
|
|
@@ -150,7 +161,7 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
150
161
|
r.queryHandler(
|
|
151
162
|
"metrics:net-worth-assets",
|
|
152
163
|
z.object({ region: z.string().optional() }),
|
|
153
|
-
async () => ({ value: "
|
|
164
|
+
async (_query, ctx) => ({ value: demoMoney(120000, "EUR", ctx.locale) }),
|
|
154
165
|
{
|
|
155
166
|
access: {
|
|
156
167
|
openToAll: {
|
|
@@ -164,7 +175,7 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
164
175
|
r.queryHandler(
|
|
165
176
|
"metrics:net-worth-debts",
|
|
166
177
|
z.object({ region: z.string().optional() }),
|
|
167
|
-
async () => ({ value: "
|
|
178
|
+
async (_query, ctx) => ({ value: demoMoney(65370, "EUR", ctx.locale) }),
|
|
168
179
|
{
|
|
169
180
|
access: {
|
|
170
181
|
openToAll: {
|
|
@@ -196,10 +207,18 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
196
207
|
r.queryHandler(
|
|
197
208
|
"metrics:latest-items",
|
|
198
209
|
z.object({}),
|
|
199
|
-
async () => ({
|
|
210
|
+
async (_query, ctx) => ({
|
|
200
211
|
rows: [
|
|
201
|
-
{
|
|
202
|
-
|
|
212
|
+
{
|
|
213
|
+
id: "i1",
|
|
214
|
+
name: demoText("widgets:dashboard:demo-incident-timeout", ctx.locale),
|
|
215
|
+
status: "resolved",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "i2",
|
|
219
|
+
name: demoText("widgets:dashboard:demo-incident-certificate", ctx.locale),
|
|
220
|
+
status: "done",
|
|
221
|
+
},
|
|
203
222
|
],
|
|
204
223
|
nextCursor: null,
|
|
205
224
|
}),
|
|
@@ -249,10 +268,16 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
249
268
|
r.queryHandler(
|
|
250
269
|
"metrics:upcoming-events",
|
|
251
270
|
z.object({}),
|
|
252
|
-
async () => ({
|
|
271
|
+
async (_query, ctx) => ({
|
|
253
272
|
rows: [
|
|
254
|
-
{
|
|
255
|
-
|
|
273
|
+
{
|
|
274
|
+
primary: demoText("widgets:dashboard:demo-event-rate-adjustment", ctx.locale),
|
|
275
|
+
trailing: demoMonthYear(2026, 8, ctx.locale),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
primary: demoText("widgets:dashboard:demo-event-savings-contract", ctx.locale),
|
|
279
|
+
trailing: demoMonthYear(2026, 10, ctx.locale),
|
|
280
|
+
},
|
|
256
281
|
],
|
|
257
282
|
}),
|
|
258
283
|
{
|
|
@@ -268,10 +293,18 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
|
|
|
268
293
|
r.queryHandler(
|
|
269
294
|
"metrics:goal-progress",
|
|
270
295
|
z.object({}),
|
|
271
|
-
async () => ({
|
|
296
|
+
async (_query, ctx) => ({
|
|
272
297
|
rows: [
|
|
273
|
-
{
|
|
274
|
-
|
|
298
|
+
{
|
|
299
|
+
label: demoText("widgets:dashboard:demo-goal-mortgage", ctx.locale),
|
|
300
|
+
value: openAmount(42000, ctx.locale),
|
|
301
|
+
fraction: 0.71,
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
label: demoText("widgets:dashboard:demo-goal-car-loan", ctx.locale),
|
|
305
|
+
value: openAmount(3200, ctx.locale),
|
|
306
|
+
fraction: 0.92,
|
|
307
|
+
},
|
|
275
308
|
],
|
|
276
309
|
}),
|
|
277
310
|
{
|
|
@@ -27,6 +27,34 @@ export const WIDGETS_I18N = {
|
|
|
27
27
|
"widgets:dashboard:filter-region": { de: "Region", en: "Region" },
|
|
28
28
|
"widgets:dashboard:filter-region-eu": { de: "Europa", en: "Europe" },
|
|
29
29
|
"widgets:dashboard:filter-region-us": { de: "USA", en: "USA" },
|
|
30
|
+
"widgets:dashboard:filter-echo-title": { de: "Custom-Panel", en: "Custom panel" },
|
|
31
|
+
"widgets:dashboard:filter-echo-filtered": {
|
|
32
|
+
de: "Gefiltert nach Region: {region}",
|
|
33
|
+
en: "Filtered by region: {region}",
|
|
34
|
+
},
|
|
35
|
+
"widgets:dashboard:filter-echo-unfiltered": {
|
|
36
|
+
de: "Ungefiltert (alle Regionen)",
|
|
37
|
+
en: "Unfiltered (all regions)",
|
|
38
|
+
},
|
|
39
|
+
"widgets:dashboard:demo-incident-timeout": {
|
|
40
|
+
de: "API-Timeout eu-central",
|
|
41
|
+
en: "API timeout eu-central",
|
|
42
|
+
},
|
|
43
|
+
"widgets:dashboard:demo-incident-certificate": {
|
|
44
|
+
de: "Zertifikat erneuert",
|
|
45
|
+
en: "Certificate renewed",
|
|
46
|
+
},
|
|
47
|
+
"widgets:dashboard:demo-event-rate-adjustment": {
|
|
48
|
+
de: "Zinsanpassung Baudarlehen",
|
|
49
|
+
en: "Mortgage rate adjustment",
|
|
50
|
+
},
|
|
51
|
+
"widgets:dashboard:demo-event-savings-contract": {
|
|
52
|
+
de: "Bausparvertrag zuteilungsreif",
|
|
53
|
+
en: "Building savings contract ready for allocation",
|
|
54
|
+
},
|
|
55
|
+
"widgets:dashboard:demo-goal-mortgage": { de: "Baudarlehen", en: "Mortgage" },
|
|
56
|
+
"widgets:dashboard:demo-goal-car-loan": { de: "Autokredit", en: "Car loan" },
|
|
57
|
+
"widgets:dashboard:demo-amount-open": { de: "{amount} offen", en: "{amount} outstanding" },
|
|
30
58
|
"widgets:catalog:portfolio": { de: "Portfolio", en: "Portfolio" },
|
|
31
59
|
"widgets:catalog:portfolio-sub": { de: "über 4 Konten", en: "across 4 accounts" },
|
|
32
60
|
"widgets:catalog:remaining-debt": { de: "Restschuld", en: "Remaining debt" },
|
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
// gewählten Screen-Filter-Wert über `filterParams` sieht (siehe
|
|
4
4
|
// DashboardFilterDefinition in feature.ts).
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { type ExtensionSectionProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import { SectionCard } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
8
|
import type { ReactNode } from "react";
|
|
9
9
|
|
|
10
10
|
export function DashboardFilterEcho({ filterParams }: ExtensionSectionProps): ReactNode {
|
|
11
|
+
const t = useTranslation();
|
|
11
12
|
const region = filterParams?.["region"];
|
|
12
13
|
return (
|
|
13
|
-
<SectionCard title="
|
|
14
|
+
<SectionCard title={t("widgets:dashboard:filter-echo-title")}>
|
|
14
15
|
<p className="text-sm text-muted-foreground">
|
|
15
16
|
{typeof region === "string"
|
|
16
|
-
?
|
|
17
|
-
: "
|
|
17
|
+
? t("widgets:dashboard:filter-echo-filtered", { region })
|
|
18
|
+
: t("widgets:dashboard:filter-echo-unfiltered")}
|
|
18
19
|
</p>
|
|
19
20
|
</SectionCard>
|
|
20
21
|
);
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
Minimal pattern for **tokenized anonymous read surfaces**: authenticated users
|
|
4
4
|
mint a link; visitors resolve it without login.
|
|
5
5
|
|
|
6
|
-
Domain-agnostic — no credit/folder logic.
|
|
7
|
-
|
|
8
|
-
(`share-by-token` + 5 layout templates).
|
|
6
|
+
Domain-agnostic — no credit/folder logic. A full consumer adds snapshots,
|
|
7
|
+
layout templates and tier gates on top.
|
|
9
8
|
|
|
10
9
|
## What it shows
|
|
11
10
|
|
|
@@ -16,8 +15,8 @@ Domain-agnostic — no credit/folder logic. Full consumer:
|
|
|
16
15
|
- **`anonymousAccess`** on test stack (see integration test)
|
|
17
16
|
|
|
18
17
|
Client wiring: [`recipes/apex-surface-auth`](../apex-surface-auth/) or a gate
|
|
19
|
-
before `createKumikoApp` (
|
|
20
|
-
|
|
18
|
+
before `createKumikoApp` (a small gate component that renders the public view when
|
|
19
|
+
the URL carries a share token).
|
|
21
20
|
|
|
22
21
|
## Handlers
|
|
23
22
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Pattern: authenticated create → plain token show-once → anonymous
|
|
4
4
|
// read-by-token with SHA-256 lookup. Revoke sets revokedAt via executor.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// A full consumer adds domain snapshots, layouts and tier gates on top.
|
|
7
7
|
|
|
8
8
|
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
9
9
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -54,6 +54,12 @@ export const APP_FEATURES = [
|
|
|
54
54
|
For an app that does not mount `dsgvoSelfServiceFeatures()`, use
|
|
55
55
|
`securityBaselineFeatures()` with its default options, as this recipe does.
|
|
56
56
|
|
|
57
|
+
Dedupe keeps the first mounted instance. Late-bound state such as
|
|
58
|
+
`bindAutoRevokeOnPasswordChange` must be bound on the instance returned by
|
|
59
|
+
`composeFeatures`/`createApp`, not on a locally held handle to the dropped one:
|
|
60
|
+
the dropped instance's hooks never run and password changes silently stop
|
|
61
|
+
revoking sessions.
|
|
62
|
+
|
|
57
63
|
A `createSessionsFeature({ ... })` mounted with different options than the
|
|
58
64
|
preset's own no-arg instance still produces a clear boot error — dedupe only
|
|
59
65
|
collapses provably interchangeable instances, never silently picks one:
|
|
@@ -20,7 +20,7 @@ import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-feat
|
|
|
20
20
|
export const invoiceEntity = createEntity({
|
|
21
21
|
table: "read_invoices",
|
|
22
22
|
fields: {
|
|
23
|
-
title: createTextField({ required: true }),
|
|
23
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
24
24
|
amount: createMoneyField({ required: true }),
|
|
25
25
|
language: createSelectField({ options: ["en", "de", "fr"] as const }),
|
|
26
26
|
},
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
|
|
3
|
-
// `where`-rules are read-path only (buildOwnershipClause). The write path
|
|
4
|
-
// decides in memory against the concrete row, so a where-rule there can only
|
|
5
|
-
// ever deny — boot validation rejects the shape (fw#2626). Feature factories
|
|
6
|
-
// that accept an `ownership` option reject it earlier, at build time, so the
|
|
7
|
-
// author gets the option name in the message instead of an entity scope.
|
|
8
|
-
export function hasWhereRule(map: OwnershipMap | undefined): boolean {
|
|
9
|
-
if (!map) return false;
|
|
10
|
-
return Object.values(map).some((rule) => rule !== "all" && rule.kind === "where");
|
|
11
|
-
}
|