@cosmicdrift/kumiko-bundled-features 0.331.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));
@@ -6,19 +6,18 @@
6
6
  */
7
7
  export declare function capCounterAggregateId(tenantId: string, capName: string, periodStartIso: string): string;
8
8
  /**
9
- * Deterministic aggregate-id für ein Rolling-Window-Counter-Aggregate
10
- * aus dem Paar (tenantId, capName). Pro Tenant + Cap existiert genau
11
- * EIN Rolling-Aggregate-Stream — die Window-Semantik kommt rein aus
12
- * dem Read-Pfad (Filter via event-store-Timestamp).
9
+ * Deterministic aggregate id for a rolling-window counter, derived from
10
+ * (tenantId, capName). Exactly ONE rolling aggregate stream exists per
11
+ * tenant + cap; window semantics come purely from the read path
12
+ * (filtering on the event-store timestamp), so no period is part of the id.
13
13
  *
14
- * **Eigener Namespace:** kollidiert NICHT mit
15
- * `capCounterAggregateId(tenantId, capName, "1970-01-01...")` — selbe
16
- * inputs, andere uuidv5-namespace, anderer Output-UUID. Damit ist auch
17
- * verhindert dass ein versehentlicher Calendar-Increment auf den
18
- * Rolling-Stream trifft.
14
+ * **Separate namespace:** does NOT collide with
15
+ * `capCounterAggregateId(tenantId, capName, "1970-01-01...")`. Same inputs,
16
+ * different uuidv5 namespace, different output UUID. This also prevents an
17
+ * accidental calendar increment from landing on the rolling stream.
19
18
  *
20
- * **Aufruf-Pattern:** Caller (incrementRollingCap-Helper) ruft mit
21
- * tenantId + capName auf, erzeugt Increment-Events am stream. Race-
22
- * frei: der event-store hängt mit auto-incrementing version an.
19
+ * **Caller:** `CapCounterHandlers.incrementRolling` (via `ctx.write`) calls
20
+ * this with tenantId + capName and appends increment events to the stream.
21
+ * Race-free because the event store appends with an auto-incrementing version.
23
22
  */
24
23
  export declare function rollingCapAggregateId(tenantId: string, capName: string): string;
@@ -21,20 +21,19 @@ export function capCounterAggregateId(tenantId, capName, periodStartIso) {
21
21
  return uuidv5(`${tenantId}|${capName}|${periodStartIso}`, CAP_COUNTER_NAMESPACE);
22
22
  }
23
23
  /**
24
- * Deterministic aggregate-id für ein Rolling-Window-Counter-Aggregate
25
- * aus dem Paar (tenantId, capName). Pro Tenant + Cap existiert genau
26
- * EIN Rolling-Aggregate-Stream — die Window-Semantik kommt rein aus
27
- * dem Read-Pfad (Filter via event-store-Timestamp).
24
+ * Deterministic aggregate id for a rolling-window counter, derived from
25
+ * (tenantId, capName). Exactly ONE rolling aggregate stream exists per
26
+ * tenant + cap; window semantics come purely from the read path
27
+ * (filtering on the event-store timestamp), so no period is part of the id.
28
28
  *
29
- * **Eigener Namespace:** kollidiert NICHT mit
30
- * `capCounterAggregateId(tenantId, capName, "1970-01-01...")` — selbe
31
- * inputs, andere uuidv5-namespace, anderer Output-UUID. Damit ist auch
32
- * verhindert dass ein versehentlicher Calendar-Increment auf den
33
- * Rolling-Stream trifft.
29
+ * **Separate namespace:** does NOT collide with
30
+ * `capCounterAggregateId(tenantId, capName, "1970-01-01...")`. Same inputs,
31
+ * different uuidv5 namespace, different output UUID. This also prevents an
32
+ * accidental calendar increment from landing on the rolling stream.
34
33
  *
35
- * **Aufruf-Pattern:** Caller (incrementRollingCap-Helper) ruft mit
36
- * tenantId + capName auf, erzeugt Increment-Events am stream. Race-
37
- * frei: der event-store hängt mit auto-incrementing version an.
34
+ * **Caller:** `CapCounterHandlers.incrementRolling` (via `ctx.write`) calls
35
+ * this with tenantId + capName and appends increment events to the stream.
36
+ * Race-free because the event store appends with an auto-incrementing version.
38
37
  */
39
38
  // @wrapper-known uuid-domain
40
39
  export function rollingCapAggregateId(tenantId, capName) {
@@ -17,25 +17,24 @@
17
17
  // Pre-Save-Wrapper mit asymmetrischen Soft/Hard-Toleranzen pro
18
18
  // Cap-Profile.
19
19
  //
20
- // **Calendar vs. Rolling — wann welches:**
21
- // - **Calendar-Period** (incrementCap + enforceCap): Cap resettet
22
- // sich am Period-Start (1. des Monats etc.). Counter ist 1 Row in
23
- // der projection. Schneller Read.
24
- // - **Rolling-Window** (incrementRollingCap + enforceRollingCap):
25
- // Cap rollt kontinuierlich (z.B. "letzten 7 Tage"). Werte
26
- // verfallen Event-für-Event ohne Reset. Kein projection — Read
27
- // summiert über die letzten N Tage Events.
20
+ // **Calendar vs. Rolling — when to use which:**
21
+ // - **Calendar period** (bookCapUsage + enforceCap): the cap resets at
22
+ // period start (1st of the month etc.). The counter is one row in the
23
+ // projection. Fast read.
24
+ // - **Rolling window** (CapCounterHandlers.incrementRolling + enforceRollingCap):
25
+ // the cap rolls continuously (e.g. "last 7 days"). Values expire
26
+ // event by event without a reset. No projection; the read sums the
27
+ // events of the last N days.
28
28
  //
29
- // **Was diese Feature NICHT macht:**
30
- // - **Kein Foundation-Wiring.** mail-foundation / file-foundation /
31
- // ai-foundation sind heute BYOK-default und haben keinen Plattform-
32
- // Pool zum Zählen. Cap-Counter ist generic — wenn ein App-Owner
33
- // den Counter nutzen will, ruft er `incrementCap(...)` /
34
- // `incrementRollingCap(...)` aus seinem eigenen Handler auf.
35
- // - **Kein Reset-Cron für Calendar-Period.** Funktioniert ohne —
36
- // der periodStartIso-Bestandteil der aggregate-id rollt am
37
- // Period-Tick natürlich auf einen frischen Counter. Alte Rows
38
- // bleiben für Audit liegen.
29
+ // **What this feature does NOT do:**
30
+ // - **No foundation wiring.** mail-foundation / file-foundation /
31
+ // ai-foundation are BYOK by default today and have no platform pool
32
+ // to count. cap-counter is generic: an app owner who wants to use the
33
+ // counter calls `bookCapUsage(...)` /
34
+ // `ctx.write(CapCounterHandlers.incrementRolling, ...)` from their own handler.
35
+ // - **No reset cron for the calendar period.** Works without one: the
36
+ // periodStartIso part of the aggregate id rolls over to a fresh
37
+ // counter at the period tick. Old rows stay for audit.
39
38
  // - **Kein Notification-Pfad als Hard-Wiring.** Cap-counter
40
39
  // entkoppelt — `enforceCapAndMaybeNotify` (siehe enforce-cap.ts)
41
40
  // ist ein Convenience-Helper, der einen Caller-supplied
@@ -85,6 +85,9 @@ export function createTenantCapsListQuery(caps, listCaps) {
85
85
  totalCount: z.boolean().optional(),
86
86
  }),
87
87
  access: { roles: ["SystemAdmin"] },
88
+ escapeHatch: {
89
+ reason: "cap-overview:tenant-caps:list — grants app-owned CapSpec.usage()/usageBatch() providers raw SQL for aggregates TenantDb has no typed helper for (e.g. a SUM)",
90
+ },
88
91
  handler: async (query, ctx) => {
89
92
  if (!ctx.systemDb) {
90
93
  throw new InternalError({
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
3
3
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
4
4
  import { configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
5
- import { nullBlindIndexesForSubject, recordRowExistsInTenant, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
5
+ import { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
6
6
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
@@ -10,6 +10,7 @@ import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framewor
10
10
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
11
11
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
12
12
  import * as z from "zod";
13
+ import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
13
14
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
14
15
  import { USER_STATUS } from "../../user/index.js";
15
16
  import { denyIfTargetOutsideAdminTenant, isSystemAdminActor, updateUserLifecycle, } from "../../user-data-rights/index.js";
@@ -99,23 +100,38 @@ async function resolveTenantScopeDenial(db, features, user, raw) {
99
100
  const ownedInTenant = await subjectRowExistsInTenant(db, features, raw.userId, user.tenantId);
100
101
  return ownedInTenant ? undefined : memberDenied;
101
102
  }
102
- // fw#2789: the host entity's OWN retention declaration gets the last word on
103
- // a targeted row-shred — blockDelete (legally mandated physical retention,
104
- // e.g. ledger/invoice text) means this command must refuse, not silently
105
- // anonymize or proceed. Deliberately independent of the tenant feature/gate
106
- // above: a retention obligation holds in single-tenant apps too, and running
107
- // unconditionally (not nested under `features.has("tenant")`) keeps that
108
- // true. Only the entity's declared `retention.strategy` is consulted here —
109
- // NOT the data-retention feature's tenant-preset/override layering
110
- // (resolveRetentionPolicy) — so a tenant cannot override its own way past a
111
- // blockDelete declared on the entity.
112
- function resolveRetentionDenial(features, raw) {
103
+ // fw#2789/#2805: blockDelete (legally mandated physical retention, e.g.
104
+ // ledger/invoice text) means this command must refuse, not silently
105
+ // anonymize or proceed. Refused when the host entity itself declares
106
+ // blockDelete OR the owning tenant's effective data-retention policy
107
+ // (preset/override layering) does, so a tenant preset cannot be bypassed here.
108
+ // The entity declaration is checked first and independent of the tenant
109
+ // feature/gate and of data-retention being mounted: a retention obligation
110
+ // holds in single-tenant apps too.
111
+ async function resolveRetentionDenial(ctx, raw,
112
+ // Lazy: unsafeRaw writes an audit entry per call, so only request it once a read is needed.
113
+ getRunner) {
113
114
  if (raw.kind !== "record")
114
115
  return undefined;
116
+ const features = ctx.registry.features;
117
+ const deny = () => writeFailure(new AccessDeniedError({ details: { reason: TARGET_RECORD_RETENTION_BLOCK_DELETE } }));
115
118
  const entity = findRegisteredEntity(features, raw.entity);
116
- if (entity?.retention?.strategy !== "blockDelete")
119
+ if (entity?.retention?.strategy === "blockDelete")
120
+ return deny();
121
+ if (!features.has("data-retention"))
117
122
  return undefined;
118
- return writeFailure(new AccessDeniedError({ details: { reason: TARGET_RECORD_RETENTION_BLOCK_DELETE } }));
123
+ // The row's own tenant, not the actor's: a SystemAdmin acts across tenants.
124
+ const runner = getRunner();
125
+ const owningTenantId = await recordRowOwningTenantId(runner, features, raw.entity, raw.id);
126
+ if (owningTenantId === undefined)
127
+ return undefined;
128
+ const effective = await resolveRetentionPolicyForTenant({
129
+ db: runner,
130
+ registry: ctx.registry,
131
+ tenantId: owningTenantId,
132
+ entityName: raw.entity,
133
+ });
134
+ return effective.policy?.strategy === "blockDelete" ? deny() : undefined;
119
135
  }
120
136
  // Denied cross-tenant probes must still leave an audit trail (fw#2348).
121
137
  // Appended outside the handler tx (fw#2592): the caller's
@@ -190,7 +206,7 @@ export const forgetSubjectWrite = defineWriteHandler({
190
206
  agent: { expose: false, risk: "high" },
191
207
  escapeHatch: {
192
208
  reason: "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
193
- "the tenant-scope check runs against the subject's tenant, not necessarily the caller's; " +
209
+ "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
194
210
  "the blind-index sweep and search purge address the subject across tenants; the user " +
195
211
  "lifecycle update and PAT revoke run on the SYSTEM user stream.",
196
212
  },
@@ -218,7 +234,7 @@ export const forgetSubjectWrite = defineWriteHandler({
218
234
  // Runs AFTER the tenant gate (VORHER only means "before the shred"): a
219
235
  // retention check ahead of the tenant gate would leak a foreign
220
236
  // entity's retention posture to a cross-tenant prober.
221
- const retentionDenial = resolveRetentionDenial(ctx.registry.features, raw);
237
+ const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"));
222
238
  if (retentionDenial) {
223
239
  // Own reason constant, not tenantScopeDenial's `.error.code` pattern:
224
240
  // AccessDeniedError.code is the generic "access_denied" for every
@@ -17,7 +17,7 @@ import { createHash } from "node:crypto";
17
17
  import { KNOWN_RECURRING_INTERVALS, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
18
18
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
19
19
  import Stripe from "stripe";
20
- import { mapStripeSubscriptionState } from "./verify-webhook.js";
20
+ import { isResourceMissingStripeError, mapStripeSubscriptionState } from "./verify-webhook.js";
21
21
  export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation = true } = {}) {
22
22
  return async (ctx, options) => {
23
23
  // #104-Invariante: ohne billing-live darf keine Stripe-Session
@@ -79,9 +79,6 @@ export function createStripeCancelSubscription(runtime) {
79
79
  await stripe.subscriptions.cancel(providerSubscriptionId);
80
80
  };
81
81
  }
82
- function isResourceMissingStripeError(error) {
83
- return (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing");
84
- }
85
82
  export function createStripeRetrieveSubscription(runtime, { priceToTier }) {
86
83
  return async (ctx, providerSubscriptionId) => {
87
84
  const stripe = await runtime.clientForCtx(ctx);
@@ -1,8 +1,9 @@
1
1
  import type { PaymentEvent, SubscriptionEvent } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
2
2
  import { type SubscriptionEventType, type SubscriptionStatus } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
3
3
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
- import type Stripe from "stripe";
4
+ import Stripe from "stripe";
5
5
  import type { StripeWebhookRuntime } from "./runtime.js";
6
+ export declare function isResourceMissingStripeError(error: unknown): boolean;
6
7
  export type StripeWebhookOptions = {
7
8
  /** Price-to-tier-Map. Plugin liest die price-id aus dem event und
8
9
  * mapped auf tier-name. Fehlt die price-id im Mapping → null. App-
@@ -25,17 +25,25 @@
25
25
  // sig-verify, kein split-brain.
26
26
  //
27
27
  // **Invoice-event lazy-fetch:**
28
- // Bei `invoice.paid` und `invoice.payment_failed` enthält der webhook-
29
- // payload nur die subscription-id (Stripe-Webhooks expanden subscription
30
- // nicht automatisch). Plugin macht einen lazy-fetch via
31
- // `stripe.subscriptions.retrieve(subId)` um an das full subscription-
32
- // Object für status/tier/period-end-mapping zu kommen. Bei Stripe-API-
33
- // failure (= subscription gelöscht zwischen webhook + retrieve)
34
- // returnt der Plugin defensiv null — der nächste subscription-event
35
- // wird den state korrekt handhaben.
28
+ // Invoice payloads carry only the subscription id (Stripe does not expand
29
+ // subscription in webhooks), so the plugin lazy-fetches via
30
+ // `stripe.subscriptions.retrieve(subId)` to get the full subscription for
31
+ // status/tier/period-end mapping. resource_missing (deleted between webhook
32
+ // and retrieve) returns null; any other Stripe error becomes 503 so Stripe
33
+ // redelivers.
36
34
  import { BillingEventKinds, SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
35
+ import { ExtraRouteRejection } from "@cosmicdrift/kumiko-framework/api";
36
+ import Stripe from "stripe";
37
37
  import { Temporal } from "temporal-polyfill";
38
38
  import { STRIPE_PROVIDER_NAME, StripeEventTypes } from "./constants.js";
39
+ export function isResourceMissingStripeError(error) {
40
+ return (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing");
41
+ }
42
+ // Anything but resource_missing (network, 5xx, rate limit) is transient: a 503 makes Stripe retry,
43
+ // whereas a silent null would be acknowledged as 200 and the event lost for good.
44
+ function transientProviderRejection(error) {
45
+ return new ExtraRouteRejection(503, { error: { code: "subscription_provider_transient" } }, `stripe retrieve failed: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
39
47
  /**
40
48
  * Stripe-webhook-handler. Implementiert den Plugin-Contract
41
49
  * `verifyAndParseWebhook`. **Pre-tenant-resolution** — kein
@@ -231,12 +239,13 @@ async function extractSubscriptionFromEvent(event, stripe) {
231
239
  try {
232
240
  return await stripe.subscriptions.retrieve(subId);
233
241
  }
234
- catch {
235
- // Stripe-API-failure beim retrieve (z.B. subscription gelöscht
236
- // zwischen webhook + retrieve). Defensive: null returnen, damit
237
- // foundation 200 ignored returnt — der nächste subscription-
238
- // event wird's korrekt handhaben.
239
- return null;
242
+ catch (error) {
243
+ // Subscription deleted between webhook and retrieve: null makes the
244
+ // foundation answer 200 ignored; the next subscription event carries
245
+ // the state.
246
+ if (isResourceMissingStripeError(error))
247
+ return null;
248
+ throw transientProviderRejection(error);
240
249
  }
241
250
  }
242
251
  default:
@@ -263,16 +272,18 @@ async function parsePaymentEvent(event, stripe) {
263
272
  // Lazy-fetch with expand: the raw webhook payload carries neither
264
273
  // line_items (needed for priceId) nor an expanded payment_intent (needed
265
274
  // for tenantId — see below). Same lazy-fetch pattern as
266
- // extractSubscriptionFromEvent's invoice-branch above, same defensive
267
- // null on API failure (session gone/expired between webhook + retrieve).
275
+ // extractSubscriptionFromEvent's invoice-branch above, null only for resource_missing
276
+ // (session gone between webhook + retrieve), other failures reject with 503.
268
277
  let expanded;
269
278
  try {
270
279
  expanded = await stripe.checkout.sessions.retrieve(session.id, {
271
280
  expand: ["line_items", "payment_intent"],
272
281
  });
273
282
  }
274
- catch {
275
- return null;
283
+ catch (error) {
284
+ if (isResourceMissingStripeError(error))
285
+ return null;
286
+ throw transientProviderRejection(error);
276
287
  }
277
288
  // Tenant-resolution: mode:"payment" checkout sessions carry tenantId on
278
289
  // the PaymentIntent's OWN metadata (plugin-methods.ts sets it via
@@ -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.331.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.331.0",
487
- "@cosmicdrift/kumiko-framework": "0.331.0",
488
- "@cosmicdrift/kumiko-headless": "0.331.0",
489
- "@cosmicdrift/kumiko-renderer": "0.331.0",
490
- "@cosmicdrift/kumiko-renderer-web": "0.331.0",
491
- "@cosmicdrift/kumiko-types": "0.331.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.331.0",
987
- "@cosmicdrift/kumiko-locale-es": "0.331.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.332.0",
4
+ "type": "fix",
5
+ "title": "tenant-caps:list declares its unsafeRaw escapeHatch"
6
+ },
2
7
  {
3
8
  "version": "0.318.0",
4
9
  "type": "improvement",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.332.0",
4
+ "type": "fix",
5
+ "title": "forget-subject respects tenant retention blockDelete"
6
+ },
2
7
  {
3
8
  "version": "0.167.0",
4
9
  "type": "breaking",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.332.0",
4
+ "type": "fix",
5
+ "title": "Stripe retrieve transient errors answer 503 instead of ignored"
6
+ },
2
7
  {
3
8
  "version": "0.320.0",
4
9
  "type": "fix",
@@ -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",