@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.273.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-composition.boot.test.ts +25 -7
- package/src/__tests__/account-security-composition.integration.test.ts +23 -1
- package/src/__tests__/account-security-fixture.ts +3 -0
- package/src/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
- package/src/agent-tools/__tests__/feature.test.ts +10 -4
- 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-email-password/handlers/invite-accept.write.ts +8 -1
- package/src/auth-mfa/handlers/disable.write.ts +7 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
- package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
- package/src/auth-mfa/handlers/status.query.ts +7 -1
- package/src/auth-mfa/screens.ts +26 -3
- package/src/cap-counter/feature.ts +5 -3
- package/src/cap-overview/screens.ts +6 -0
- package/src/channel-in-app/handlers/inbox.query.ts +7 -1
- package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
- package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
- package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
- package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
- package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
- package/src/config/__tests__/config.integration.test.ts +1 -1
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
- package/src/config/__tests__/tz-resolution.integration.test.ts +1 -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 +13 -1
- package/src/config/handlers/readiness.query.ts +13 -1
- package/src/config/handlers/reencrypt.job.ts +21 -2
- package/src/config/handlers/reset.write.ts +7 -1
- package/src/config/handlers/schema.query.ts +13 -1
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/handlers/values.query.ts +13 -1
- package/src/data-retention/handlers/policy-for.query.ts +8 -1
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +39 -5
- package/src/delivery/changes.json +6 -0
- package/src/delivery/delivery-service.ts +25 -3
- package/src/delivery/handlers/preferences.query.ts +13 -1
- package/src/delivery/handlers/set-preference.write.ts +7 -1
- package/src/delivery/testing.ts +8 -1
- package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
- package/src/folders/__tests__/feature.test.ts +14 -5
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
- package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
- package/src/folders/__tests__/folders.integration.test.ts +6 -1
- package/src/folders/feature.ts +2 -2
- package/src/form-draft/constants.ts +7 -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-read-gate.integration.test.ts +10 -2
- package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
- package/src/notes-history/feature.ts +2 -2
- package/src/notes-history/web/i18n.ts +3 -0
- package/src/notes-history/web/notes-section.tsx +61 -51
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
- package/src/personal-access-tokens/handlers/create.write.ts +7 -1
- package/src/personal-access-tokens/handlers/list.query.ts +7 -1
- package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
- package/src/personal-access-tokens/screens.ts +15 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
- package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
- package/src/secrets/feature.ts +2 -2
- package/src/sessions/handlers/mine.query.ts +7 -1
- package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
- package/src/sessions/handlers/revoke.write.ts +7 -1
- package/src/sessions/screens.ts +9 -1
- package/src/tags/__tests__/feature.test.ts +25 -8
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
- package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
- package/src/tags/__tests__/tags.integration.test.ts +6 -1
- package/src/tags/constants.ts +1 -1
- package/src/tags/feature.ts +2 -2
- package/src/tags/screens.ts +3 -0
- package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
- package/src/tenant/changes.json +6 -0
- package/src/tenant/handlers/me.query.ts +13 -1
- 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 +13 -1
- 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/__tests__/inspector-screens.boot.test.ts +6 -1
- package/src/user-data-rights/feature.ts +11 -6
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +7 -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/handlers/export-status.query.ts +7 -1
- package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
- package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
- package/src/user-data-rights/handlers/request-export.write.ts +7 -1
- package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
- package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
- package/src/user-data-rights/screens.ts +3 -0
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
- package/src/user-profile/feature.ts +11 -1
- package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
|
@@ -12,7 +12,13 @@ import { userSessionTable } from "../schema/user-session";
|
|
|
12
12
|
export const mineQuery = definePagedQueryHandler({
|
|
13
13
|
name: "user-session:mine",
|
|
14
14
|
schema: z.object({}),
|
|
15
|
-
access: {
|
|
15
|
+
access: {
|
|
16
|
+
openToAll: {
|
|
17
|
+
reason:
|
|
18
|
+
"each signed-in user lists only their own live sessions; the query filters " +
|
|
19
|
+
"userSessionTable by the caller's own userId",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
16
22
|
description:
|
|
17
23
|
"Lists the calling user's own still-live sessions, newest first, each flagged whether it is the one making the request; use it to show a user their signed-in devices.",
|
|
18
24
|
outputSchema: z.object({
|
|
@@ -15,7 +15,13 @@ import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../ses
|
|
|
15
15
|
export const revokeAllOthersWrite = defineWriteHandler({
|
|
16
16
|
name: "user-session:revoke-all-others",
|
|
17
17
|
schema: z.object({}),
|
|
18
|
-
access: {
|
|
18
|
+
access: {
|
|
19
|
+
openToAll: {
|
|
20
|
+
reason:
|
|
21
|
+
"each signed-in user revokes only their own other sessions; the update is " +
|
|
22
|
+
"scoped to the caller's own userId, keeping only their current session (sid)",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
19
25
|
description:
|
|
20
26
|
'Irreversibly signs the calling user out of every session except the one making the request and reports how many were dropped; use it for a "sign out everywhere else" action after a suspected compromise.',
|
|
21
27
|
agent: { risk: "high" },
|
|
@@ -28,7 +28,13 @@ export const revokeWrite = defineWriteHandler({
|
|
|
28
28
|
schema: z.object({
|
|
29
29
|
id: z.uuid(),
|
|
30
30
|
}),
|
|
31
|
-
access: {
|
|
31
|
+
access: {
|
|
32
|
+
openToAll: {
|
|
33
|
+
reason:
|
|
34
|
+
"each signed-in user revokes only their own session by id; the update is " +
|
|
35
|
+
"scoped to the caller's own userId",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
32
38
|
description:
|
|
33
39
|
"Irreversibly signs one of the caller's own sessions out by id; use it when a user wants to drop a single device, and note that revoking their current session logs them out.",
|
|
34
40
|
agent: { risk: "high" },
|
package/src/sessions/screens.ts
CHANGED
|
@@ -140,5 +140,13 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
140
140
|
confirm: i18nKey("sessions.mine.revokeAllOthers.confirm"),
|
|
141
141
|
},
|
|
142
142
|
],
|
|
143
|
-
access: {
|
|
143
|
+
access: {
|
|
144
|
+
openToAll: {
|
|
145
|
+
reason:
|
|
146
|
+
"each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers",
|
|
147
|
+
},
|
|
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,
|
|
144
152
|
};
|
|
@@ -105,7 +105,9 @@ describe("createTagsFeature access-options", () => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
108
|
-
const feature = createTagsFeature({
|
|
108
|
+
const feature = createTagsFeature({
|
|
109
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
110
|
+
});
|
|
109
111
|
for (const path of [
|
|
110
112
|
"create-tag",
|
|
111
113
|
"update-tag",
|
|
@@ -116,17 +118,28 @@ describe("createTagsFeature access-options", () => {
|
|
|
116
118
|
"assign-tag",
|
|
117
119
|
"remove-tag",
|
|
118
120
|
]) {
|
|
119
|
-
expect(rawWriteAccess(feature, path)).toEqual({
|
|
121
|
+
expect(rawWriteAccess(feature, path)).toEqual({
|
|
122
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
123
|
+
});
|
|
120
124
|
}
|
|
121
125
|
for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
|
|
122
|
-
expect(rawQueryAccess(feature, query)).toEqual({
|
|
126
|
+
expect(rawQueryAccess(feature, query)).toEqual({
|
|
127
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
128
|
+
});
|
|
123
129
|
}
|
|
124
130
|
});
|
|
125
131
|
|
|
126
132
|
test("access takes precedence over the roles shorthand", () => {
|
|
127
|
-
const feature = createTagsFeature({
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
const feature = createTagsFeature({
|
|
134
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
135
|
+
roles: ["Admin"],
|
|
136
|
+
});
|
|
137
|
+
expect(rawWriteAccess(feature, "create-tag")).toEqual({
|
|
138
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
139
|
+
});
|
|
140
|
+
expect(rawQueryAccess(feature, "tag:list")).toEqual({
|
|
141
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
142
|
+
});
|
|
130
143
|
});
|
|
131
144
|
|
|
132
145
|
test("access:{roles} threads through like the roles shorthand", () => {
|
|
@@ -139,12 +152,16 @@ describe("createTagsFeature access-options", () => {
|
|
|
139
152
|
describe("createTagsFeature toggleable-option (tier-gating)", () => {
|
|
140
153
|
test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
|
|
141
154
|
expect(createTagsFeature().toggleableDefault).toBeUndefined();
|
|
142
|
-
expect(
|
|
155
|
+
expect(
|
|
156
|
+
createTagsFeature({
|
|
157
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
158
|
+
}).toggleableDefault,
|
|
159
|
+
).toBeUndefined();
|
|
143
160
|
});
|
|
144
161
|
|
|
145
162
|
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
146
163
|
const feature = createTagsFeature({
|
|
147
|
-
access: { openToAll:
|
|
164
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
148
165
|
toggleable: { default: false },
|
|
149
166
|
});
|
|
150
167
|
expect(feature.toggleableDefault).toBe(false);
|
|
@@ -93,7 +93,12 @@ let tagId: string;
|
|
|
93
93
|
|
|
94
94
|
beforeAll(async () => {
|
|
95
95
|
stack = await setupTestStack({
|
|
96
|
-
features: [
|
|
96
|
+
features: [
|
|
97
|
+
createTagsFeature({
|
|
98
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
99
|
+
}),
|
|
100
|
+
hostFixturesFeature,
|
|
101
|
+
],
|
|
97
102
|
});
|
|
98
103
|
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
99
104
|
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
@@ -99,7 +99,12 @@ let tagId: string;
|
|
|
99
99
|
beforeAll(async () => {
|
|
100
100
|
stack = await setupTestStack({
|
|
101
101
|
// Deliberately no `ownership` — the gate must hold on a bare mount.
|
|
102
|
-
features: [
|
|
102
|
+
features: [
|
|
103
|
+
createTagsFeature({
|
|
104
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
105
|
+
}),
|
|
106
|
+
hostFixturesFeature,
|
|
107
|
+
],
|
|
103
108
|
});
|
|
104
109
|
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
105
110
|
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
@@ -303,7 +308,7 @@ describe("tags read-gate — ownership narrows further, it does not replace the
|
|
|
303
308
|
ownedStack = await setupTestStack({
|
|
304
309
|
features: [
|
|
305
310
|
createTagsFeature({
|
|
306
|
-
access: { openToAll:
|
|
311
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
307
312
|
// An extra row rule on the assignment row itself, unrelated to who
|
|
308
313
|
// may see the host: the caller's claim must name the row's host type.
|
|
309
314
|
ownership: {
|
|
@@ -457,7 +457,12 @@ describe("tags integration — openToAll access model", () => {
|
|
|
457
457
|
|
|
458
458
|
beforeAll(async () => {
|
|
459
459
|
openStack = await setupTestStack({
|
|
460
|
-
features: [
|
|
460
|
+
features: [
|
|
461
|
+
createTagsFeature({
|
|
462
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
463
|
+
}),
|
|
464
|
+
hostFixturesFeature,
|
|
465
|
+
],
|
|
461
466
|
});
|
|
462
467
|
await unsafeCreateEntityTable(openStack.db, tagEntity);
|
|
463
468
|
await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
|
package/src/tags/constants.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const TagsQueries = {
|
|
|
55
55
|
export const DEFAULT_TAG_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
56
56
|
|
|
57
57
|
// The default access rule applied to every tag handler when the app passes
|
|
58
|
-
// neither `access` nor `roles`. createTagsFeature({ access: { openToAll:
|
|
58
|
+
// neither `access` nor `roles`. createTagsFeature({ access: { openToAll: { reason } } })
|
|
59
59
|
// makes tagging reachable for any authenticated tenant user — matching apps
|
|
60
60
|
// whose other handlers are openToAll rather than role-gated.
|
|
61
61
|
export const DEFAULT_TAG_ACCESS: AccessRule = { roles: DEFAULT_TAG_ROLES };
|
package/src/tags/feature.ts
CHANGED
|
@@ -54,7 +54,7 @@ function registerTags(
|
|
|
54
54
|
parents: readonly string[] | undefined,
|
|
55
55
|
): void {
|
|
56
56
|
r.describe(
|
|
57
|
-
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll:
|
|
57
|
+
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
58
58
|
);
|
|
59
59
|
r.uiHints({
|
|
60
60
|
displayLabel: "Tags",
|
|
@@ -134,7 +134,7 @@ export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
|
134
134
|
|
|
135
135
|
export type TagsFeatureOptions = {
|
|
136
136
|
/** Access rule for all tag write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
|
|
137
|
-
* Adopt the host's model — e.g. { openToAll:
|
|
137
|
+
* Adopt the host's model — e.g. { openToAll: { reason: "..." } } when the host lets any
|
|
138
138
|
* authenticated tenant user tag (like the rest of its handlers), or
|
|
139
139
|
* { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
|
|
140
140
|
readonly access?: AccessRule;
|
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
|
|
@@ -25,7 +25,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
|
|
|
25
25
|
"read-tz",
|
|
26
26
|
z.object({}),
|
|
27
27
|
async (_event, ctx) => ({ isSuccess: true, data: { tenant: ctx.tz.tenant } }),
|
|
28
|
-
{ access: { openToAll:
|
|
28
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
29
29
|
);
|
|
30
30
|
});
|
|
31
31
|
|
|
@@ -35,7 +35,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
|
|
|
35
35
|
const queryProbeFeature = defineFeature("tz-cache-probe", (r) => {
|
|
36
36
|
r.requires("tenant");
|
|
37
37
|
r.queryHandler("read-tz", z.object({}), async (_query, ctx) => ({ tenant: ctx.tz.tenant }), {
|
|
38
|
-
access: { openToAll:
|
|
38
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -401,7 +401,11 @@ describe("scenario 7: access rules on handlers", () => {
|
|
|
401
401
|
"SystemAdmin",
|
|
402
402
|
]);
|
|
403
403
|
expect(stack.registry.getQueryHandler(TenantQueries.me)?.access).toEqual({
|
|
404
|
-
openToAll:
|
|
404
|
+
openToAll: {
|
|
405
|
+
reason:
|
|
406
|
+
"any signed-in user reads only their own active tenant; the query is scoped " +
|
|
407
|
+
"to the caller's own tenantId",
|
|
408
|
+
},
|
|
405
409
|
});
|
|
406
410
|
});
|
|
407
411
|
});
|
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",
|
|
@@ -9,7 +9,19 @@ import { tenantTable } from "../schema/tenant";
|
|
|
9
9
|
export const meQuery = defineQueryHandler({
|
|
10
10
|
name: "me",
|
|
11
11
|
schema: z.object({}),
|
|
12
|
-
access: {
|
|
12
|
+
access: {
|
|
13
|
+
openToAll: {
|
|
14
|
+
reason:
|
|
15
|
+
"any signed-in user reads only their own active tenant; the query is scoped " +
|
|
16
|
+
"to the caller's own tenantId",
|
|
17
|
+
},
|
|
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
|
+
},
|
|
13
25
|
description:
|
|
14
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.",
|
|
15
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",
|
|
@@ -11,7 +11,19 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
|
|
|
11
11
|
export const meQuery = defineQueryHandler({
|
|
12
12
|
name: "user:me",
|
|
13
13
|
schema: z.object({}),
|
|
14
|
-
access: {
|
|
14
|
+
access: {
|
|
15
|
+
openToAll: {
|
|
16
|
+
reason:
|
|
17
|
+
"each signed-in user reads only their own identity record; the detail lookup " +
|
|
18
|
+
"is by the caller's own id",
|
|
19
|
+
},
|
|
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
|
+
},
|
|
15
27
|
description:
|
|
16
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.",
|
|
17
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";
|
|
@@ -94,7 +94,12 @@ describe("privacy-center screen (fw#2312 projectionDetail conversion)", () => {
|
|
|
94
94
|
expect(screen?.type).toBe("projectionDetail");
|
|
95
95
|
if (screen?.type === "projectionDetail") {
|
|
96
96
|
expect(screen.query).toBe("user:query:user:me");
|
|
97
|
-
expect(screen.access).toEqual({
|
|
97
|
+
expect(screen.access).toEqual({
|
|
98
|
+
openToAll: {
|
|
99
|
+
reason:
|
|
100
|
+
"each signed-in user manages GDPR self-service actions (export, restriction, deletion) for only their own account; the bound query and every action handler operate on the caller's own userId, and no app role name is portable enough to gate this",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
98
103
|
}
|
|
99
104
|
});
|
|
100
105
|
|
|
@@ -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
|
|
@@ -344,7 +341,15 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
344
341
|
type: "projectionDetail",
|
|
345
342
|
query: USER_ME_QUERY,
|
|
346
343
|
singleton: true,
|
|
347
|
-
access: {
|
|
344
|
+
access: {
|
|
345
|
+
openToAll: {
|
|
346
|
+
reason:
|
|
347
|
+
"each signed-in user manages GDPR self-service actions (export, restriction, " +
|
|
348
|
+
"deletion) for only their own account; the bound query and every action handler " +
|
|
349
|
+
"operate on the caller's own userId, and no app role name is portable enough to gate this",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
dormant: true,
|
|
348
353
|
description:
|
|
349
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).",
|
|
350
355
|
fieldLabels: {
|
|
@@ -22,7 +22,13 @@ const APPEND_LIFECYCLE_EVENT_REASON =
|
|
|
22
22
|
export const cancelDeletionWrite = defineWriteHandler({
|
|
23
23
|
name: "cancel-deletion",
|
|
24
24
|
schema: z.object({}),
|
|
25
|
-
access: {
|
|
25
|
+
access: {
|
|
26
|
+
openToAll: {
|
|
27
|
+
reason:
|
|
28
|
+
"each signed-in user cancels only their own pending deletion request; the " +
|
|
29
|
+
"lookup and update are scoped to the caller's own id",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
26
32
|
description:
|
|
27
33
|
"Withdraws the calling user's own pending account-deletion request and puts the account back to active, accepted only while the grace period is still running.",
|
|
28
34
|
escapeHatch: {
|
|
@@ -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
|
},
|
|
@@ -63,7 +63,13 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
63
63
|
schema: z.object({
|
|
64
64
|
jobId: z.string().min(1, "jobId required"),
|
|
65
65
|
}),
|
|
66
|
-
access: {
|
|
66
|
+
access: {
|
|
67
|
+
openToAll: {
|
|
68
|
+
reason:
|
|
69
|
+
"each signed-in user downloads only their own finished export job; the job " +
|
|
70
|
+
"lookup checks jobRow.userId against the caller's own id and 404s otherwise",
|
|
71
|
+
},
|
|
72
|
+
}, // openToAll = auth-required, kein anonymous
|
|
67
73
|
description:
|
|
68
74
|
"Returns a short-lived signed download URL for the calling user's own finished data-export job named by job id, backing the download button in the privacy center once export-status reports the job done.",
|
|
69
75
|
escapeHatch: {
|