@cosmicdrift/kumiko-bundled-features 0.332.0 → 0.333.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.
@@ -23,7 +23,8 @@ export type InviteCreateOptions = {
23
23
  readonly locale?: AuthMailLocale;
24
24
  readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
25
25
  /** Opt-in allow-list for app-defined (unranked) roles the elevation guard
26
- * would otherwise reject with unassignable_membership_role (fw#2398). */
26
+ * would otherwise reject with unassignable_membership_role (fw#2398).
27
+ * Rank-aware alternative: declare roles via EXT_ASSIGNABLE_ROLE. */
27
28
  readonly additionalAssignableRoles?: readonly string[];
28
29
  };
29
30
  export declare function createInviteCreateHandler(opts: InviteCreateOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"invite-create", z.ZodObject<{
@@ -18,7 +18,7 @@
18
18
  // invite-create.
19
19
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
20
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
21
- import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
21
+ import { access, assignableAppRolesFromUsages, defineWriteHandler, EXT_ASSIGNABLE_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
23
23
  import { Temporal } from "temporal-polyfill";
24
24
  import * as z from "zod";
@@ -54,7 +54,7 @@ export function createInviteCreateHandler(opts) {
54
54
  if (forbiddenRole !== undefined) {
55
55
  return writeFailure(reservedMembershipRoleError(forbiddenRole));
56
56
  }
57
- const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [event.payload.role], []);
57
+ const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [event.payload.role], [], assignableAppRolesFromUsages(ctx.registry.getExtensionUsages(EXT_ASSIGNABLE_ROLE)));
58
58
  if (elevationForbidden !== undefined &&
59
59
  !opts.additionalAssignableRoles?.includes(elevationForbidden)) {
60
60
  return writeFailure(unassignableMembershipRoleError(elevationForbidden));
@@ -11,7 +11,7 @@ export declare const MEMBER_STATUS_CELL_COMPONENT: "MemberStatusCell";
11
11
  export declare const MEMBER_ROLES_CELL_COMPONENT: "MemberRolesCell";
12
12
  /** Closed allowlist for invite-role picker — never free text (escalation guard). */
13
13
  export declare const DEFAULT_INVITE_ROLE_OPTIONS: readonly ["User", "Editor", "Admin"];
14
- export declare const OWNER_INVITE_ROLE_OPTIONS: readonly ["User", "Editor", "Admin", "TenantAdmin"];
14
+ export declare const OWNER_INVITE_ROLE_OPTIONS: readonly ["User", "Member", "Editor", "Admin", "TenantAdmin"];
15
15
  export declare const TenantHandlers: {
16
16
  readonly create: "tenant:write:create";
17
17
  readonly update: "tenant:write:update";
@@ -22,7 +22,13 @@ export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"];
22
22
  // Full assignable rank list, used by memberRolesEditScreen: that screen is
23
23
  // prefilled with a member's *current* roles, so it must be able to represent
24
24
  // every rank a membership can actually hold, including TenantAdmin (fw#2452).
25
- export const OWNER_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"];
25
+ export const OWNER_INVITE_ROLE_OPTIONS = [
26
+ "User",
27
+ "Member",
28
+ "Editor",
29
+ "Admin",
30
+ "TenantAdmin",
31
+ ];
26
32
  // Qualified write handler names (QN format: scope:type:name)
27
33
  export const TenantHandlers = {
28
34
  create: "tenant:write:create",
@@ -1,4 +1,4 @@
1
- import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type AssignableAppRoles, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export { tenantEntity, tenantTable } from "./schema/tenant.js";
3
3
  export type TenantFeatureOptions = {
4
4
  /** Adds the /members "invite" drawer button + its `invite-create`
@@ -9,5 +9,8 @@ export type TenantFeatureOptions = {
9
9
  * handler lookup fails). Off by default: `tenant` cannot see whether an
10
10
  * app configured that optional auth-email-password flow. */
11
11
  readonly inviteScreen?: boolean;
12
+ /** Screen options only — the elevation guard always reads the registry. */
13
+ readonly assignableAppRoles?: AssignableAppRoles;
12
14
  };
15
+ export declare function collectAssignableAppRoles(features: readonly FeatureDefinition[]): AssignableAppRoles;
13
16
  export declare function createTenantFeature(options?: TenantFeatureOptions): FeatureDefinition;
@@ -1,4 +1,4 @@
1
- import { access, createSystemConfig, createTenantConfig, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, assignableAppRolesFromUsages, createSystemConfig, createTenantConfig, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, EXT_ASSIGNABLE_ROLE, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { activeTenantIdsQuery } from "./handlers/active-tenant-ids.query.js";
3
3
  import { addMemberWrite } from "./handlers/add-member.write.js";
4
4
  import { cancelInvitationWrite } from "./handlers/cancel-invitation.write.js";
@@ -20,8 +20,13 @@ import { TENANT_I18N } from "./i18n.js";
20
20
  import { tenantInvitationEntity } from "./invitation-table.js";
21
21
  import { tenantMembershipEntity } from "./membership-table.js";
22
22
  import { tenantEntity } from "./schema/tenant.js";
23
- import { createMembersScreen, inviteCreateScreen, memberRolesEditScreen, tenantEditScreen, tenantListScreen, } from "./screens.js";
23
+ import { createInviteCreateScreen, createMemberRolesEditScreen, createMembersScreen, tenantEditScreen, tenantListScreen, } from "./screens.js";
24
24
  export { tenantEntity, tenantTable } from "./schema/tenant.js";
25
+ export function collectAssignableAppRoles(features) {
26
+ return assignableAppRolesFromUsages(features
27
+ .flatMap((feature) => feature.extensionUsages)
28
+ .filter((usage) => usage.extensionName === EXT_ASSIGNABLE_ROLE));
29
+ }
25
30
  // --- Feature ---
26
31
  export function createTenantFeature(options) {
27
32
  return defineFeature("tenant", (r) => {
@@ -33,6 +38,10 @@ export function createTenantFeature(options) {
33
38
  });
34
39
  r.systemScope();
35
40
  r.requires("config");
41
+ r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
42
+ r.bootCheck(({ features }) => {
43
+ collectAssignableAppRoles(features);
44
+ });
36
45
  r.entity("tenant", tenantEntity);
37
46
  r.entity("tenant-membership", tenantMembershipEntity);
38
47
  r.entity("tenant-invitation", tenantInvitationEntity);
@@ -128,9 +137,9 @@ export function createTenantFeature(options) {
128
137
  // §2.6), invite via a drawer-hosted actionForm, role-edit via
129
138
  // actionForm. Screen access matches handler access.admin.
130
139
  r.screen(createMembersScreen(options));
131
- r.screen(memberRolesEditScreen);
140
+ r.screen(createMemberRolesEditScreen(options?.assignableAppRoles));
132
141
  if (options?.inviteScreen) {
133
- r.screen(inviteCreateScreen);
142
+ r.screen(createInviteCreateScreen(options.assignableAppRoles));
134
143
  }
135
144
  r.nav({
136
145
  id: "members",
@@ -1,6 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
- import { access, createSystemUser, defineWriteHandler, findForbiddenRoleAssignment, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { access, assignableAppRolesFromUsages, createSystemUser, defineWriteHandler, EXT_ASSIGNABLE_ROLE, findForbiddenRoleAssignment, withResponseData, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
6
6
  import * as z from "zod";
@@ -70,7 +70,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
70
70
  const row = existing; // @cast-boundary generic-record
71
71
  const currentTargetRoles = parseRoles(row["roles"]);
72
72
  if (!event.user.roles.includes("system")) {
73
- const forbiddenElevation = findForbiddenRoleAssignment(event.user.roles, event.payload.roles, currentTargetRoles);
73
+ const forbiddenElevation = findForbiddenRoleAssignment(event.user.roles, event.payload.roles, currentTargetRoles, assignableAppRolesFromUsages(ctx.registry.getExtensionUsages(EXT_ASSIGNABLE_ROLE)));
74
74
  if (forbiddenElevation !== undefined) {
75
75
  return writeFailure(unassignableMembershipRoleError(forbiddenElevation));
76
76
  }
@@ -24,12 +24,14 @@ export const TENANT_I18N = {
24
24
  "tenant:entity:__action-form__:field:email": { en: "Email" },
25
25
  "tenant:entity:__action-form__:field:role": { en: "Role" },
26
26
  "tenant:entity:__action-form__:field:role:option:User": { en: "User" },
27
+ "tenant:entity:__action-form__:field:role:option:Member": { en: "Member" },
27
28
  "tenant:entity:__action-form__:field:role:option:Admin": { en: "Admin" },
28
29
  "tenant:entity:__action-form__:field:role:option:TenantAdmin": { en: "Tenant Admin" },
29
30
  "tenant:entity:__action-form__:field:role:option:Editor": { en: "Editor" },
30
31
  "tenant:entity:__action-form__:field:userId": { en: "User ID" },
31
32
  "tenant:entity:__action-form__:field:roles": { en: "Roles" },
32
33
  "tenant:entity:__action-form__:field:roles:option:User": { en: "User" },
34
+ "tenant:entity:__action-form__:field:roles:option:Member": { en: "Member" },
33
35
  "tenant:entity:__action-form__:field:roles:option:Admin": { en: "Admin" },
34
36
  "tenant:entity:__action-form__:field:roles:option:TenantAdmin": { en: "Tenant Admin" },
35
37
  "tenant:entity:__action-form__:field:roles:option:Editor": { en: "Editor" },
@@ -1,6 +1,6 @@
1
1
  export { TenantCommandSchemas } from "./command-schemas.js";
2
2
  export { TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries } from "./constants.js";
3
- export { createTenantFeature } from "./feature.js";
3
+ export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
4
4
  export type { InvitationStatus } from "./invitation-table.js";
5
5
  export { INVITATION_STATUS, INVITATION_STATUSES, tenantInvitationEntity, tenantInvitationsTable, } from "./invitation-table.js";
6
6
  export { isTenantServingPublicContent } from "./is-tenant-serving-public-content.js";
@@ -1,6 +1,6 @@
1
1
  export { TenantCommandSchemas } from "./command-schemas.js";
2
2
  export { TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries } from "./constants.js";
3
- export { createTenantFeature } from "./feature.js";
3
+ export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
4
4
  export { INVITATION_STATUS, INVITATION_STATUSES, tenantInvitationEntity, tenantInvitationsTable, } from "./invitation-table.js";
5
5
  export { isTenantServingPublicContent } from "./is-tenant-serving-public-content.js";
6
6
  export { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table.js";
@@ -1,9 +1,35 @@
1
- import { type EntityEditScreenDefinition, type EntityListScreenDefinition, type ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type AssignableAppRoles, type EntityEditScreenDefinition, type EntityListScreenDefinition, type ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const tenantListScreen: EntityListScreenDefinition;
3
3
  export declare const tenantEditScreen: EntityEditScreenDefinition;
4
4
  export declare function createMembersScreen(options?: {
5
5
  readonly inviteScreen?: boolean;
6
6
  }): ScreenDefinition;
7
+ export declare function createInviteCreateScreen(assignableAppRoles?: AssignableAppRoles): {
8
+ id: "invite-create";
9
+ type: "actionForm";
10
+ handler: "auth-email-password:write:invite-create";
11
+ fields: {
12
+ email: {
13
+ type: "text";
14
+ required: true;
15
+ format: "email";
16
+ };
17
+ role: {
18
+ type: "select";
19
+ options: string[];
20
+ required: true;
21
+ };
22
+ };
23
+ layout: {
24
+ sections: {
25
+ fields: string[];
26
+ }[];
27
+ };
28
+ submitLabel: string;
29
+ access: {
30
+ roles: readonly string[];
31
+ };
32
+ };
7
33
  export declare const inviteCreateScreen: {
8
34
  id: "invite-create";
9
35
  type: "actionForm";
@@ -16,7 +42,7 @@ export declare const inviteCreateScreen: {
16
42
  };
17
43
  role: {
18
44
  type: "select";
19
- options: readonly ["User", "Editor", "Admin"];
45
+ options: string[];
20
46
  required: true;
21
47
  };
22
48
  };
@@ -30,6 +56,36 @@ export declare const inviteCreateScreen: {
30
56
  roles: readonly string[];
31
57
  };
32
58
  };
59
+ export declare function createMemberRolesEditScreen(assignableAppRoles?: AssignableAppRoles): {
60
+ id: "member-roles-edit";
61
+ type: "actionForm";
62
+ handler: "tenant:write:update-member-roles";
63
+ redirect: "members";
64
+ cancelTarget: "members";
65
+ fields: {
66
+ userId: {
67
+ type: "text";
68
+ required: true;
69
+ };
70
+ roles: {
71
+ type: "multiSelect";
72
+ options: string[];
73
+ required: true;
74
+ };
75
+ };
76
+ layout: {
77
+ sections: {
78
+ fields: (string | {
79
+ field: string;
80
+ readOnly: true;
81
+ })[];
82
+ }[];
83
+ };
84
+ submitLabel: string;
85
+ access: {
86
+ roles: readonly string[];
87
+ };
88
+ };
33
89
  export declare const memberRolesEditScreen: {
34
90
  id: "member-roles-edit";
35
91
  type: "actionForm";
@@ -43,7 +99,7 @@ export declare const memberRolesEditScreen: {
43
99
  };
44
100
  roles: {
45
101
  type: "multiSelect";
46
- options: readonly ["User", "Editor", "Admin", "TenantAdmin"];
102
+ options: string[];
47
103
  required: true;
48
104
  };
49
105
  };
@@ -1,5 +1,5 @@
1
1
  import { AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/constants";
2
- import { access, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, i18nKey, isAssignableByRole, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { DEFAULT_INVITE_ROLE_OPTIONS, INVITE_CREATE_SCREEN_ID, MEMBER_ROLES_CELL_COMPONENT, MEMBER_ROLES_EDIT_SCREEN_ID, MEMBER_STATUS_CELL_COMPONENT, MEMBERS_SCREEN_ID, OWNER_INVITE_ROLE_OPTIONS, TenantHandlers, TenantQueries, } from "./constants.js";
4
4
  // Cross-tenant SystemAdmin platform view of the tenants themselves. The tenant
5
5
  // feature runs with `r.systemScope()`, so the entityList returns every tenant.
@@ -138,38 +138,56 @@ export function createMembersScreen(options) {
138
138
  // only entry point is the drawer button on /members. Only registered by
139
139
  // feature.ts when `TenantFeatureOptions.inviteScreen` is set — see
140
140
  // createMembersScreen's doc comment above for why.
141
- export const inviteCreateScreen = {
142
- id: INVITE_CREATE_SCREEN_ID,
143
- type: "actionForm",
144
- handler: AuthHandlers.inviteCreate,
145
- fields: {
146
- email: { type: "text", required: true, format: "email" },
147
- role: { type: "select", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
148
- },
149
- layout: {
150
- sections: [{ fields: ["email", "role"] }],
151
- },
152
- submitLabel: i18nKey("tenant.members.invite.submit"),
153
- access: { roles: access.admin },
154
- };
155
- export const memberRolesEditScreen = {
156
- id: MEMBER_ROLES_EDIT_SCREEN_ID,
157
- type: "actionForm",
158
- handler: TenantHandlers.updateMemberRoles,
159
- redirect: MEMBERS_SCREEN_ID,
160
- cancelTarget: MEMBERS_SCREEN_ID,
161
- fields: {
162
- userId: { type: "text", required: true },
163
- // Prefilled with the member's current roles, so the option list must be
164
- // able to represent every assignable rank — DEFAULT_INVITE_ROLE_OPTIONS
165
- // omits TenantAdmin and would silently strip it on submit. Escalation
166
- // still stays server-side via findForbiddenRoleAssignment (update-member-roles.write.ts).
167
- roles: { type: "multiSelect", options: OWNER_INVITE_ROLE_OPTIONS, required: true },
168
- },
169
- layout: {
170
- // Prefill userId from rowAction; readOnly so the operator cannot retarget.
171
- sections: [{ fields: [{ field: "userId", readOnly: true }, "roles"] }],
172
- },
173
- submitLabel: i18nKey("tenant.members.roles.edit.submit"),
174
- access: { roles: access.admin },
175
- };
141
+ export function createInviteCreateScreen(assignableAppRoles = new Map()) {
142
+ // Admin sees this screen too, so only app roles Admin may grant are offered (fw#2414).
143
+ const appRoleOptions = [...assignableAppRoles]
144
+ .filter(([, assignableFrom]) => isAssignableByRole(assignableFrom, "Admin"))
145
+ .map(([role]) => role);
146
+ return {
147
+ id: INVITE_CREATE_SCREEN_ID,
148
+ type: "actionForm",
149
+ handler: AuthHandlers.inviteCreate,
150
+ fields: {
151
+ email: { type: "text", required: true, format: "email" },
152
+ role: {
153
+ type: "select",
154
+ options: [...DEFAULT_INVITE_ROLE_OPTIONS, ...appRoleOptions],
155
+ required: true,
156
+ },
157
+ },
158
+ layout: {
159
+ sections: [{ fields: ["email", "role"] }],
160
+ },
161
+ submitLabel: i18nKey("tenant.members.invite.submit"),
162
+ access: { roles: access.admin },
163
+ };
164
+ }
165
+ export const inviteCreateScreen = createInviteCreateScreen();
166
+ export function createMemberRolesEditScreen(assignableAppRoles = new Map()) {
167
+ return {
168
+ id: MEMBER_ROLES_EDIT_SCREEN_ID,
169
+ type: "actionForm",
170
+ handler: TenantHandlers.updateMemberRoles,
171
+ redirect: MEMBERS_SCREEN_ID,
172
+ cancelTarget: MEMBERS_SCREEN_ID,
173
+ fields: {
174
+ userId: { type: "text", required: true },
175
+ // Prefilled with the member's current roles, so the option list must be
176
+ // able to represent every assignable rank — DEFAULT_INVITE_ROLE_OPTIONS
177
+ // omits TenantAdmin and would silently strip it on submit. Escalation
178
+ // still stays server-side via findForbiddenRoleAssignment (update-member-roles.write.ts).
179
+ roles: {
180
+ type: "multiSelect",
181
+ options: [...OWNER_INVITE_ROLE_OPTIONS, ...assignableAppRoles.keys()],
182
+ required: true,
183
+ },
184
+ },
185
+ layout: {
186
+ // Prefill userId from rowAction; readOnly so the operator cannot retarget.
187
+ sections: [{ fields: [{ field: "userId", readOnly: true }, "roles"] }],
188
+ },
189
+ submitLabel: i18nKey("tenant.members.roles.edit.submit"),
190
+ access: { roles: access.admin },
191
+ };
192
+ }
193
+ export const memberRolesEditScreen = createMemberRolesEditScreen();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.332.0",
3
+ "version": "0.333.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -483,12 +483,12 @@
483
483
  }
484
484
  },
485
485
  "dependencies": {
486
- "@cosmicdrift/kumiko-dispatcher-live": "0.332.0",
487
- "@cosmicdrift/kumiko-framework": "0.332.0",
488
- "@cosmicdrift/kumiko-headless": "0.332.0",
489
- "@cosmicdrift/kumiko-renderer": "0.332.0",
490
- "@cosmicdrift/kumiko-renderer-web": "0.332.0",
491
- "@cosmicdrift/kumiko-types": "0.332.0",
486
+ "@cosmicdrift/kumiko-dispatcher-live": "0.333.0",
487
+ "@cosmicdrift/kumiko-framework": "0.333.0",
488
+ "@cosmicdrift/kumiko-headless": "0.333.0",
489
+ "@cosmicdrift/kumiko-renderer": "0.333.0",
490
+ "@cosmicdrift/kumiko-renderer-web": "0.333.0",
491
+ "@cosmicdrift/kumiko-types": "0.333.0",
492
492
  "@mollie/api-client": "^4.5.0",
493
493
  "@node-rs/argon2": "^2.0.2",
494
494
  "@types/mailparser": "^3.4.6",
@@ -983,8 +983,8 @@
983
983
  ],
984
984
  "devDependencies": {
985
985
  "@testing-library/user-event": "^14.6.1",
986
- "@cosmicdrift/kumiko-locale-de": "0.332.0",
987
- "@cosmicdrift/kumiko-locale-es": "0.332.0",
986
+ "@cosmicdrift/kumiko-locale-de": "0.333.0",
987
+ "@cosmicdrift/kumiko-locale-es": "0.333.0",
988
988
  "jsqr": "^1.4.0"
989
989
  }
990
990
  }
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.333.0",
4
+ "type": "improvement",
5
+ "title": "Apps declare assignable membership roles via EXT_ASSIGNABLE_ROLE"
6
+ },
2
7
  {
3
8
  "version": "0.313.0",
4
9
  "type": "fix",