@cosmicdrift/kumiko-bundled-features 0.270.0 → 0.274.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 +9 -9
- package/src/__tests__/account-security-fixture.ts +3 -0
- package/src/agent-tools/tool-catalog.ts +1 -1
- package/src/agent-tools/tool-dispatch.ts +1 -1
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
- package/src/audit/changes.json +12 -0
- package/src/audit/escape-hatch-audit-sink.ts +52 -0
- package/src/audit/feature.ts +5 -1
- package/src/audit/index.ts +1 -0
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
- package/src/auth-mfa/screens.ts +14 -10
- package/src/cap-counter/feature.ts +5 -3
- package/src/cap-overview/screens.ts +6 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
- package/src/config/changes.json +6 -0
- package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
- package/src/config/db/queries/resolver.ts +22 -22
- package/src/config/handlers/cascade.query.ts +6 -0
- package/src/config/handlers/readiness.query.ts +6 -0
- package/src/config/handlers/reencrypt.job.ts +21 -2
- package/src/config/handlers/schema.query.ts +6 -0
- package/src/config/handlers/values.query.ts +6 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +34 -0
- package/src/delivery/changes.json +6 -0
- package/src/delivery/delivery-service.ts +25 -3
- package/src/delivery/handlers/preferences.query.ts +6 -0
- package/src/delivery/testing.ts +8 -1
- package/src/jobs/handlers/list.query.ts +3 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
- package/src/managed-pages/screens/branding-screen.ts +3 -0
- package/src/managed-pages/screens/page-screens.ts +3 -0
- package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
- package/src/notes-history/web/i18n.ts +3 -0
- package/src/notes-history/web/notes-section.tsx +61 -51
- package/src/personal-access-tokens/screens.ts +7 -7
- package/src/sessions/screens.ts +5 -3
- package/src/tags/screens.ts +3 -0
- package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
- package/src/tenant/changes.json +6 -0
- package/src/tenant/handlers/me.query.ts +6 -0
- package/src/tenant/handlers/update-member-roles.write.ts +10 -1
- package/src/tenant/screens.ts +3 -0
- package/src/tenant/seeding.ts +25 -19
- package/src/tier-engine/feature.ts +3 -0
- package/src/user/changes.json +6 -0
- package/src/user/handlers/me.query.ts +6 -0
- package/src/user/handlers/update-roles.ts +8 -5
- package/src/user/screens.ts +3 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
- package/src/user-data-rights/feature.ts +3 -5
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
- package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
- package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
- package/src/user-data-rights/screens.ts +3 -0
- package/src/user-profile/feature.ts +3 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
13
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
15
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
16
|
import {
|
|
16
17
|
createTestUser,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
type TestStack,
|
|
19
20
|
unsafeCreateEntityTable,
|
|
20
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
|
|
21
23
|
import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
|
|
22
24
|
import { accountEntity, scheduleEntity, transactionEntity } from "../entity";
|
|
23
25
|
import { createLedgerFeature } from "../feature";
|
|
@@ -544,6 +546,61 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
|
|
|
544
546
|
});
|
|
545
547
|
});
|
|
546
548
|
|
|
549
|
+
describe("ledger integration — createSchedule with a caller-chosen id (idempotent job creates)", () => {
|
|
550
|
+
// An event-triggered job derives the schedule id deterministically from
|
|
551
|
+
// its own contract line instead of matching on description text. It acts
|
|
552
|
+
// as createSystemUser(tenantId, extraRoles) — extraRoles clears the
|
|
553
|
+
// handler's normal TenantAdmin/TenantMember access gate while roles still
|
|
554
|
+
// carrying "system" is what lets the create handler honor the id at all.
|
|
555
|
+
const jobUser = createSystemUser(admin.tenantId, ["TenantAdmin"]);
|
|
556
|
+
|
|
557
|
+
test("the chosen id is honored; redelivering the same event is recognized, not duplicated", async () => {
|
|
558
|
+
const bank = await createAccount("Bank", "asset");
|
|
559
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
560
|
+
const scheduleId = generateDeterministicId(`ledger:schedule:${admin.tenantId}`, "contract-42");
|
|
561
|
+
const payload = {
|
|
562
|
+
description: "Miete WE1",
|
|
563
|
+
startDate: "2026-01-01",
|
|
564
|
+
interval: "monthly",
|
|
565
|
+
amount: 50000,
|
|
566
|
+
debitAccountId: bank,
|
|
567
|
+
creditAccountId: rent,
|
|
568
|
+
id: scheduleId,
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
572
|
+
LedgerHandlers.createSchedule,
|
|
573
|
+
payload,
|
|
574
|
+
jobUser,
|
|
575
|
+
);
|
|
576
|
+
expect(created.id).toBe(scheduleId);
|
|
577
|
+
|
|
578
|
+
const redelivered = await stack.http.writeErr(LedgerHandlers.createSchedule, payload, jobUser);
|
|
579
|
+
expect(redelivered.code).toBe("version_conflict");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("an ordinary tenant user's chosen id is ignored — create still succeeds with a fresh id", async () => {
|
|
583
|
+
const bank = await createAccount("Bank", "asset");
|
|
584
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
585
|
+
const chosenId = generateDeterministicId(`ledger:schedule:${admin.tenantId}`, "contract-99");
|
|
586
|
+
|
|
587
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
588
|
+
LedgerHandlers.createSchedule,
|
|
589
|
+
{
|
|
590
|
+
description: "Miete WE2",
|
|
591
|
+
startDate: "2026-01-01",
|
|
592
|
+
interval: "monthly",
|
|
593
|
+
amount: 30000,
|
|
594
|
+
debitAccountId: bank,
|
|
595
|
+
creditAccountId: rent,
|
|
596
|
+
id: chosenId,
|
|
597
|
+
},
|
|
598
|
+
admin,
|
|
599
|
+
);
|
|
600
|
+
expect(created.id).not.toBe(chosenId);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
547
604
|
describe("ledger integration — subject dimension (filterable business-object reference)", () => {
|
|
548
605
|
test("a booking with subjectType/subjectId round-trips and is findable via an eq filter", async () => {
|
|
549
606
|
const bank = await createAccount("Bank", "asset");
|
|
@@ -81,6 +81,9 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
81
81
|
],
|
|
82
82
|
},
|
|
83
83
|
access: { roles: ADMIN_ROLES },
|
|
84
|
+
// See file header: nav placement is app-specific, so this opts out of
|
|
85
|
+
// the boot-validator's nav-area check.
|
|
86
|
+
dormant: true,
|
|
84
87
|
};
|
|
85
88
|
if (!opts.allowCustomCss) return base;
|
|
86
89
|
return {
|
|
@@ -52,6 +52,9 @@ export const pageListScreen: EntityListScreenDefinition = {
|
|
|
52
52
|
},
|
|
53
53
|
],
|
|
54
54
|
access: { roles: ADMIN_ROLES },
|
|
55
|
+
// Nav/placement is app-specific (see file header) — no nav area to
|
|
56
|
+
// resolve in isolation.
|
|
57
|
+
dormant: true,
|
|
55
58
|
};
|
|
56
59
|
|
|
57
60
|
export const pageEditScreen: EntityEditScreenDefinition = {
|
|
@@ -127,6 +127,29 @@ function renderSection(): ReturnType<typeof render> {
|
|
|
127
127
|
return render(<NotesSection entityName="contact" entityId="contact-1" />, { wrapper });
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
describe("NotesSection — two-card layout", () => {
|
|
131
|
+
test("renders a 'new note' card and a 'history' card as two separate cards", async () => {
|
|
132
|
+
const view = renderSection();
|
|
133
|
+
await waitFor(() => expect(view.getByTestId("notes-section-history")).toBeTruthy());
|
|
134
|
+
|
|
135
|
+
const newNoteCard = view.getByTestId("notes-section-new");
|
|
136
|
+
const historyCard = view.getByTestId("notes-section-history");
|
|
137
|
+
expect(newNoteCard).not.toBe(historyCard);
|
|
138
|
+
expect(newNoteCard.textContent).toContain("New note");
|
|
139
|
+
expect(historyCard.textContent).toContain("History");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("history entries are separated by dividers, not individual borders", async () => {
|
|
143
|
+
const view = renderSection();
|
|
144
|
+
await waitFor(() => expect(view.getByTestId("notes-section-row-note-1")).toBeTruthy());
|
|
145
|
+
|
|
146
|
+
const row = view.getByTestId("notes-section-row-note-1");
|
|
147
|
+
expect(row.className).not.toContain("border");
|
|
148
|
+
const historyCard = view.getByTestId("notes-section-history");
|
|
149
|
+
expect(historyCard.querySelector(".divide-y")).not.toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
130
153
|
describe("NotesSection — entry display", () => {
|
|
131
154
|
test("note body and meta line render as two distinct nodes, not one run-on line", async () => {
|
|
132
155
|
const view = renderSection();
|
|
@@ -15,5 +15,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
15
15
|
"notesHistory.section.add": "Add note",
|
|
16
16
|
"notesHistory.section.meta": "{author} · {date}",
|
|
17
17
|
"notesHistory.section.authorUnknown": "Unknown author",
|
|
18
|
+
"notesHistory.section.newNoteTitle": "New note",
|
|
19
|
+
"notesHistory.section.historyTitle": "History",
|
|
20
|
+
"notesHistory.section.shortcutHint": "Ctrl+Enter saves",
|
|
18
21
|
},
|
|
19
22
|
};
|
|
@@ -48,7 +48,7 @@ export function NotesSection({
|
|
|
48
48
|
readonly entityName: string;
|
|
49
49
|
readonly entityId: string | null;
|
|
50
50
|
}): ReactNode {
|
|
51
|
-
const { Banner, Button, Text, Input } = usePrimitives();
|
|
51
|
+
const { Banner, Button, Card, Grid, Text, Input } = usePrimitives();
|
|
52
52
|
const t = useTranslation();
|
|
53
53
|
const dispatcher = useDispatcher();
|
|
54
54
|
const enabled = entityId !== null;
|
|
@@ -118,57 +118,67 @@ export function NotesSection({
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
return (
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
<
|
|
135
|
-
variant="
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
</Button>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
{rows.length === 0 ? (
|
|
146
|
-
<Text variant="small">{t("notesHistory.section.empty")}</Text>
|
|
147
|
-
) : (
|
|
148
|
-
<div className="flex flex-col gap-2">
|
|
149
|
-
{rows.map((n) => (
|
|
150
|
-
<div
|
|
151
|
-
key={n.id}
|
|
152
|
-
data-testid={`notes-section-row-${n.id}`}
|
|
153
|
-
className="flex flex-col gap-1 rounded-md border p-2"
|
|
121
|
+
<Grid columns={1} testId="notes-section">
|
|
122
|
+
<Card slots={{ title: t("notesHistory.section.newNoteTitle") }} testId="notes-section-new">
|
|
123
|
+
<Grid columns={1}>
|
|
124
|
+
<Input
|
|
125
|
+
kind="textarea"
|
|
126
|
+
id="notes-section-draft"
|
|
127
|
+
name="draft"
|
|
128
|
+
value={draft}
|
|
129
|
+
onChange={setDraft}
|
|
130
|
+
onSubmitShortcut={() => {
|
|
131
|
+
if (!busy && draft.trim() !== "") addNote();
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
<div className="flex items-center justify-between gap-2">
|
|
135
|
+
<Text variant="small">{t("notesHistory.section.shortcutHint")}</Text>
|
|
136
|
+
<Button
|
|
137
|
+
variant="primary"
|
|
138
|
+
disabled={busy || draft.trim() === ""}
|
|
139
|
+
onClick={() => addNote()}
|
|
140
|
+
testId="notes-section-add"
|
|
154
141
|
>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)}
|
|
142
|
+
{busy ? t("notesHistory.section.working") : t("notesHistory.section.add")}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
{errorKey !== null && (
|
|
146
|
+
<Banner variant="error" testId="notes-section-action-error">
|
|
147
|
+
<Text>{t(errorKey)}</Text>
|
|
148
|
+
</Banner>
|
|
149
|
+
)}
|
|
150
|
+
</Grid>
|
|
151
|
+
</Card>
|
|
166
152
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
153
|
+
<Card
|
|
154
|
+
slots={{ title: t("notesHistory.section.historyTitle") }}
|
|
155
|
+
testId="notes-section-history"
|
|
156
|
+
>
|
|
157
|
+
{rows.length === 0 ? (
|
|
158
|
+
<Text variant="small">{t("notesHistory.section.empty")}</Text>
|
|
159
|
+
) : (
|
|
160
|
+
<div className="flex flex-col divide-y">
|
|
161
|
+
{rows.map((n) => (
|
|
162
|
+
<div
|
|
163
|
+
key={n.id}
|
|
164
|
+
data-testid={`notes-section-row-${n.id}`}
|
|
165
|
+
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0"
|
|
166
|
+
>
|
|
167
|
+
<Text testId={`notes-section-body-${n.id}`}>{n.body}</Text>
|
|
168
|
+
<Text variant="small" testId={`notes-section-meta-${n.id}`}>
|
|
169
|
+
{t("notesHistory.section.meta", {
|
|
170
|
+
author: displayAuthorName(
|
|
171
|
+
n.authorName,
|
|
172
|
+
t("notesHistory.section.authorUnknown"),
|
|
173
|
+
),
|
|
174
|
+
date: formatWhen(n.insertedAt),
|
|
175
|
+
})}
|
|
176
|
+
</Text>
|
|
177
|
+
</div>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</Card>
|
|
182
|
+
</Grid>
|
|
173
183
|
);
|
|
174
184
|
}
|
|
@@ -6,11 +6,6 @@ import {
|
|
|
6
6
|
import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants";
|
|
7
7
|
import type { PatScopeConfig } from "./scopes";
|
|
8
8
|
|
|
9
|
-
const PAT_LIST_OPEN_REASON =
|
|
10
|
-
"each signed-in user views only their own personal access tokens, mirroring the list handler";
|
|
11
|
-
const PAT_MINT_OPEN_REASON =
|
|
12
|
-
"each signed-in user mints a token for their own account only, mirroring the create handler";
|
|
13
|
-
|
|
14
9
|
export const patListScreen: ProjectionListScreenDefinition = {
|
|
15
10
|
id: PAT_SCREEN_ID,
|
|
16
11
|
type: "projectionList",
|
|
@@ -54,9 +49,13 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
54
49
|
],
|
|
55
50
|
access: {
|
|
56
51
|
openToAll: {
|
|
57
|
-
reason:
|
|
52
|
+
reason:
|
|
53
|
+
"each signed-in user views only their own personal access tokens, mirroring the list handler",
|
|
58
54
|
},
|
|
59
55
|
},
|
|
56
|
+
// Self-service settings-area screen, placed by the consuming app's own
|
|
57
|
+
// r.nav() — no nav area to resolve in isolation.
|
|
58
|
+
dormant: true,
|
|
60
59
|
};
|
|
61
60
|
|
|
62
61
|
// Grant-string vocabulary for a scope config's multiSelect field — a
|
|
@@ -113,7 +112,8 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
|
|
|
113
112
|
cancelTarget: PAT_SCREEN_ID,
|
|
114
113
|
access: {
|
|
115
114
|
openToAll: {
|
|
116
|
-
reason:
|
|
115
|
+
reason:
|
|
116
|
+
"each signed-in user mints a token for their own account only, mirroring the create handler",
|
|
117
117
|
},
|
|
118
118
|
},
|
|
119
119
|
};
|
package/src/sessions/screens.ts
CHANGED
|
@@ -13,8 +13,6 @@ import {
|
|
|
13
13
|
} from "./constants";
|
|
14
14
|
|
|
15
15
|
const listAccess = { roles: access.admin };
|
|
16
|
-
const SESSION_MINE_OPEN_REASON =
|
|
17
|
-
"each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers";
|
|
18
16
|
|
|
19
17
|
export const sessionListScreen: ProjectionListScreenDefinition = {
|
|
20
18
|
id: SESSION_LIST_SCREEN_ID,
|
|
@@ -144,7 +142,11 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
144
142
|
],
|
|
145
143
|
access: {
|
|
146
144
|
openToAll: {
|
|
147
|
-
reason:
|
|
145
|
+
reason:
|
|
146
|
+
"each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers",
|
|
148
147
|
},
|
|
149
148
|
},
|
|
149
|
+
// Self-service settings-area screen, placed by the consuming app's own
|
|
150
|
+
// r.nav() — no nav area to resolve in isolation.
|
|
151
|
+
dormant: true,
|
|
150
152
|
};
|
package/src/tags/screens.ts
CHANGED
|
@@ -16,11 +16,11 @@ function membersFacetLabelKeys(): readonly string[] {
|
|
|
16
16
|
if (screen?.type !== "projectionList") {
|
|
17
17
|
throw new Error("expected members screen to be projectionList");
|
|
18
18
|
}
|
|
19
|
-
return (screen.facets ?? []).flatMap((facet) =>
|
|
20
|
-
facet.type === "select"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
);
|
|
19
|
+
return (screen.facets ?? []).flatMap((facet) => {
|
|
20
|
+
if (facet.type === "select") return [facet.label, ...facet.options.map((o) => o.label)];
|
|
21
|
+
if (facet.type === "boolean") return [facet.label, facet.trueLabel, facet.falseLabel];
|
|
22
|
+
return [facet.label];
|
|
23
|
+
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Column labels of the members screen itself — deliberately scoped to this
|
package/src/tenant/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.271.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "systemScope handlers get a default per-tenant+handler rate limit; tenant:me opts out (fw#2861).",
|
|
6
|
+
"detail": "`tenant` mounts under `r.systemScope()`, so its write/query handlers without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `tenant:query:me` (`tenant:me.query.ts`) declares `rateLimit: { disabled: true, reason: \"...\" }` — it is a self-scoped read of the caller's own active tenant, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.219.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -16,6 +16,12 @@ export const meQuery = defineQueryHandler({
|
|
|
16
16
|
"to the caller's own tenantId",
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
|
+
rateLimit: {
|
|
20
|
+
disabled: true,
|
|
21
|
+
reason:
|
|
22
|
+
"self-scoped read of the caller's own active tenant, hit on every page load; per-tenant " +
|
|
23
|
+
"bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
24
|
+
},
|
|
19
25
|
description:
|
|
20
26
|
"Returns the record of the tenant the caller is currently signed in to, or null if it is gone; use it whenever the active tenant's own name, key or settings are needed.",
|
|
21
27
|
handler: async (query, ctx) => {
|
|
@@ -122,7 +122,16 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
122
122
|
const willBeTenantAdmin = event.payload.roles.includes("TenantAdmin");
|
|
123
123
|
|
|
124
124
|
if (targetIsTenantAdmin && !willBeTenantAdmin) {
|
|
125
|
-
|
|
125
|
+
// assertNotLastTenantAdmin issues raw SQL, so it needs the raw runner, not
|
|
126
|
+
// `db` — the count is already filtered by the explicit targetTenantId.
|
|
127
|
+
const lockRunner = ctx.systemDb.unsafeRaw(
|
|
128
|
+
"tenant:write:updateMemberRoles last-TenantAdmin advisory lock + membership count",
|
|
129
|
+
);
|
|
130
|
+
const lastAdmin = await assertNotLastTenantAdmin(
|
|
131
|
+
lockRunner,
|
|
132
|
+
targetTenantId,
|
|
133
|
+
event.payload.userId,
|
|
134
|
+
);
|
|
126
135
|
if (lastAdmin !== undefined) return lastAdmin;
|
|
127
136
|
}
|
|
128
137
|
|
package/src/tenant/screens.ts
CHANGED
|
@@ -44,6 +44,9 @@ export const tenantListScreen: EntityListScreenDefinition = {
|
|
|
44
44
|
defaultSort: { field: "name", dir: "asc" },
|
|
45
45
|
searchable: true,
|
|
46
46
|
access: { roles: ["SystemAdmin"] },
|
|
47
|
+
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
48
|
+
// in isolation.
|
|
49
|
+
dormant: true,
|
|
47
50
|
};
|
|
48
51
|
|
|
49
52
|
export const tenantEditScreen: EntityEditScreenDefinition = {
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -117,19 +117,22 @@ export type SeedTenantHooks = {
|
|
|
117
117
|
// (HandlerContext from a live write), afterCommit hooks are queued onto the
|
|
118
118
|
// dispatcher's sink and flush post-commit. Without a sink (fixture / plain
|
|
119
119
|
// Connection) they still fire immediately — there is no outer TX to wait for.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
//
|
|
121
|
+
// `runner` comes from the caller, which already holds a declared DbRunner —
|
|
122
|
+
// a TenantDb exposes no runner back out to feature code.
|
|
123
|
+
function scopeSeedHookContext(
|
|
124
|
+
context: AppContext,
|
|
125
|
+
targetTenantId: TenantId,
|
|
126
|
+
runner: DbRunner,
|
|
127
|
+
): AppContext {
|
|
128
|
+
const db = createTenantDb(
|
|
129
|
+
runner,
|
|
130
|
+
targetTenantId,
|
|
131
|
+
"system",
|
|
132
|
+
context.tracer,
|
|
133
|
+
context.meter,
|
|
134
|
+
context.signal,
|
|
135
|
+
);
|
|
133
136
|
|
|
134
137
|
// HandlerContext carries `user`; AppContext only `systemUser`. Prefer the
|
|
135
138
|
// live handler user when present so notify/hasFeature keep a real SessionUser.
|
|
@@ -163,7 +166,7 @@ function scopeSeedHookContext(context: AppContext, targetTenantId: TenantId): Ap
|
|
|
163
166
|
return {
|
|
164
167
|
...context,
|
|
165
168
|
_tenantId: targetTenantId,
|
|
166
|
-
|
|
169
|
+
db,
|
|
167
170
|
...(notify !== undefined ? { notify } : {}),
|
|
168
171
|
...(scopedUser && "user" in context ? { user: scopedUser } : {}),
|
|
169
172
|
...(hasFeature ? { hasFeature } : {}),
|
|
@@ -176,16 +179,16 @@ export async function fireEntityPostSave(
|
|
|
176
179
|
entityData: SaveContext,
|
|
177
180
|
// Undefined for tenant-agnostic entities (user has no tenant_id column at
|
|
178
181
|
// seed time) — keeps hooks.context._tenantId as the caller passed it.
|
|
179
|
-
|
|
182
|
+
target?: { readonly tenantId: TenantId; readonly db: DbRunner },
|
|
180
183
|
): Promise<void> {
|
|
181
184
|
// skip: caller opted out of hooks (existing fixture/test call-sites that
|
|
182
185
|
// don't pass them keep today's hook-less behavior, see seedTenant's doc).
|
|
183
186
|
if (!hooks) return;
|
|
184
187
|
const lifecycle = createLifecycleHooks(hooks.registry);
|
|
185
188
|
const scopedContext =
|
|
186
|
-
|
|
189
|
+
target === undefined
|
|
187
190
|
? hooks.context
|
|
188
|
-
: scopeSeedHookContext(hooks.context,
|
|
191
|
+
: scopeSeedHookContext(hooks.context, target.tenantId, target.db);
|
|
189
192
|
await lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.inTransaction);
|
|
190
193
|
const runAfterCommit = () =>
|
|
191
194
|
lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.afterCommit);
|
|
@@ -239,7 +242,7 @@ export async function seedTenant(
|
|
|
239
242
|
// "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
|
|
240
243
|
// (keyed by result.entityName === "tenant") — that's exactly what
|
|
241
244
|
// tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
|
|
242
|
-
await fireEntityPostSave(hooks, "tenant:seed", result.data, options.id);
|
|
245
|
+
await fireEntityPostSave(hooks, "tenant:seed", result.data, { tenantId: options.id, db });
|
|
243
246
|
|
|
244
247
|
return { id: options.id };
|
|
245
248
|
}
|
|
@@ -301,7 +304,10 @@ export async function seedTenantMembership(
|
|
|
301
304
|
`seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
302
305
|
);
|
|
303
306
|
}
|
|
304
|
-
await fireEntityPostSave(hooks, "tenant-membership:seed", result.data,
|
|
307
|
+
await fireEntityPostSave(hooks, "tenant-membership:seed", result.data, {
|
|
308
|
+
tenantId: options.tenantId,
|
|
309
|
+
db,
|
|
310
|
+
});
|
|
305
311
|
return { id: extractMembershipId(result.data) };
|
|
306
312
|
}
|
|
307
313
|
|
|
@@ -290,6 +290,9 @@ export function createTierEngineFeature<
|
|
|
290
290
|
description:
|
|
291
291
|
"Operator form that assigns a tier to any tenant as a manual grant without a billing purchase.",
|
|
292
292
|
access: { roles: ["SystemAdmin"] },
|
|
293
|
+
// Apps place it via r.nav() (see above) — no nav area to resolve in
|
|
294
|
+
// isolation.
|
|
295
|
+
dormant: true,
|
|
293
296
|
});
|
|
294
297
|
|
|
295
298
|
// ───────────────────────────────────────────────────────────────────
|
package/src/user/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.271.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "systemScope handlers get a default per-tenant+handler rate limit; user:me opts out (fw#2861).",
|
|
6
|
+
"detail": "`user` mounts under `r.systemScope()`, so its write/query handlers without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `user:query:user:me` (`user:me.query.ts`) declares `rateLimit: { disabled: true, reason: \"...\" }` — it is a self-scoped read of the caller's own identity record, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.218.1",
|
|
4
10
|
"type": "improvement",
|
|
@@ -18,6 +18,12 @@ export const meQuery = defineQueryHandler({
|
|
|
18
18
|
"is by the caller's own id",
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
|
+
rateLimit: {
|
|
22
|
+
disabled: true,
|
|
23
|
+
reason:
|
|
24
|
+
"self-scoped read of the caller's own user record, hit on every page load; per-tenant " +
|
|
25
|
+
"bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
26
|
+
},
|
|
21
27
|
description:
|
|
22
28
|
"Returns the signed-in caller's own identity record, with the password hash stripped by field-level read access; use it whenever the current user's own profile data is needed.",
|
|
23
29
|
handler: async (query, ctx) => {
|
|
@@ -126,14 +126,17 @@ export async function applyUserRolesUpdate(
|
|
|
126
126
|
const willBeSystemAdmin = newRoles.includes("SystemAdmin");
|
|
127
127
|
|
|
128
128
|
if (wasActiveSystemAdmin && !willBeSystemAdmin) {
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
await acquireNamespacedAdvisoryLock(db, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
|
|
132
|
-
// user is r.systemScope() — the count spans every tenant's SystemAdmins,
|
|
133
|
-
// so it needs the raw runner, not the (still tenant-shaped) `db` above.
|
|
129
|
+
// user is r.systemScope() — the lock + count span every tenant's SystemAdmins,
|
|
130
|
+
// so both need the raw runner, not the tenant-shaped `db` above.
|
|
134
131
|
if (!ctx.systemDb) {
|
|
135
132
|
throw new InternalError({ message: "user:update-roles requires ctx.systemDb" });
|
|
136
133
|
}
|
|
134
|
+
const lockRunner = ctx.systemDb.unsafeRaw(
|
|
135
|
+
"advisory lock serializing concurrent last-SystemAdmin demotions",
|
|
136
|
+
);
|
|
137
|
+
// Lock before count+update so two concurrent demotions cannot both
|
|
138
|
+
// observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
|
|
139
|
+
await acquireNamespacedAdvisoryLock(lockRunner, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
|
|
137
140
|
const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
|
|
138
141
|
ctx.systemDb.unsafeRaw(
|
|
139
142
|
"jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
|
package/src/user/screens.ts
CHANGED
|
@@ -32,6 +32,9 @@ export const userListScreen: EntityListScreenDefinition = {
|
|
|
32
32
|
// Apps must boot a user search index on that tenant (offlot: bootWaitlistSearch).
|
|
33
33
|
searchable: true,
|
|
34
34
|
access: { roles: ["SystemAdmin"] },
|
|
35
|
+
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
36
|
+
// in isolation.
|
|
37
|
+
dormant: true,
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export const userEditScreen: EntityEditScreenDefinition = {
|
|
@@ -270,3 +270,19 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
270
270
|
expect(() => validateBoot([...minimalTenantLifecycleFeatures(), hooked])).not.toThrow();
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
describe("nav-area boot check — bundled dormant screens survive a real app nav", () => {
|
|
275
|
+
test("baseFeatures() (privacy-center included) boots clean once the app registers its own nav area", () => {
|
|
276
|
+
const appShell = defineFeature("app-shell", (r) => {
|
|
277
|
+
r.screen({ id: "home", type: "custom", renderer: { react: "Home" } });
|
|
278
|
+
r.nav({ id: "home", label: "app-shell:nav.home", screen: "app-shell:screen:home" });
|
|
279
|
+
r.translations({
|
|
280
|
+
keys: {
|
|
281
|
+
"app-shell:nav.home": { de: "Start", en: "Home" },
|
|
282
|
+
"screen:home.title": { de: "Start", en: "Home" },
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
expect(() => validateBoot([...baseFeatures(), appShell])).not.toThrow();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Regression: the changeset describes these as "a platform-operator
|
|
4
4
|
// inspector over the GDPR read-models" (i.e. cross-tenant), but the handlers
|
|
5
|
-
// registered no
|
|
5
|
+
// registered no escapeHatch/systemScope override — a SystemAdmin acting in
|
|
6
6
|
// tenant A saw only tenant A's export jobs, never tenant B's.
|
|
7
7
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
@@ -325,11 +325,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
325
325
|
|
|
326
326
|
// Self-service screen (Art. 15/17/18/20): export, restriction, deletion
|
|
327
327
|
// in one projectionDetail screen bound to the user's own `me` row. No
|
|
328
|
-
// r.nav — the app places it in its logged-in area
|
|
329
|
-
//
|
|
330
|
-
// type: "custom" screens, so a non-navved projectionDetail never
|
|
331
|
-
// triggers it — no `dormant` flag needed, that field doesn't exist on
|
|
332
|
-
// this screen type). access is openToAll because no app role name is
|
|
328
|
+
// r.nav — the app places it in its logged-in area; `dormant: true` opts
|
|
329
|
+
// it out of the nav-area boot check. access is openToAll because no app role name is
|
|
333
330
|
// portable; the per-user handlers enforce auth server-side.
|
|
334
331
|
//
|
|
335
332
|
// Restriction/Deletion are declarative fields + actions; Export stays a
|
|
@@ -352,6 +349,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
352
349
|
"operate on the caller's own userId, and no app role name is portable enough to gate this",
|
|
353
350
|
},
|
|
354
351
|
},
|
|
352
|
+
dormant: true,
|
|
355
353
|
description:
|
|
356
354
|
"Logged-in GDPR self-service page where a user requests and downloads a data export (Art. 20), restricts processing of their account (Art. 18) and requests its deletion (Art. 17).",
|
|
357
355
|
fieldLabels: {
|
|
@@ -9,7 +9,10 @@ export const downloadAttemptListQuery = defineEntityListHandler(
|
|
|
9
9
|
downloadAttemptEntity,
|
|
10
10
|
{
|
|
11
11
|
access: { roles: access.systemAdmin },
|
|
12
|
-
|
|
12
|
+
escapeHatch: {
|
|
13
|
+
reason:
|
|
14
|
+
"SystemAdmin DPO triage of invalid export-download attempts needs every tenant's rows to spot brute-force patterns across tenants",
|
|
15
|
+
},
|
|
13
16
|
description:
|
|
14
17
|
"Lists invalid export-download attempts across every tenant for the system-admin inspector screen; list-download-attempts is the filterable view scoped to a single tenant.",
|
|
15
18
|
},
|