@cosmicdrift/kumiko-bundled-features 0.59.1 → 0.60.1

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.
Files changed (39) hide show
  1. package/package.json +2 -1
  2. package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
  3. package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
  4. package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
  5. package/src/custom-fields/__tests__/feature.test.ts +57 -4
  6. package/src/custom-fields/feature.ts +19 -4
  7. package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
  8. package/src/files-provider-s3/s3-provider.ts +9 -3
  9. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
  10. package/src/managed-pages/handlers/set.write.ts +14 -4
  11. package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
  12. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
  13. package/src/subscription-stripe/feature.ts +2 -1
  14. package/src/tags/__tests__/feature.test.ts +34 -0
  15. package/src/tags/__tests__/tags.integration.test.ts +66 -0
  16. package/src/tags/constants.ts +11 -2
  17. package/src/tags/feature.ts +26 -21
  18. package/src/tags/handlers/assign-tag.write.ts +4 -6
  19. package/src/tags/handlers/create-tag.write.ts +4 -6
  20. package/src/tags/handlers/remove-tag.write.ts +4 -6
  21. package/src/tags/index.ts +1 -0
  22. package/src/tier-engine/__tests__/drift.test.ts +4 -0
  23. package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
  24. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
  25. package/src/tier-engine/constants.ts +13 -0
  26. package/src/tier-engine/entity.ts +5 -0
  27. package/src/tier-engine/feature.ts +51 -3
  28. package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
  29. package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
  30. package/src/tier-engine/i18n.ts +39 -0
  31. package/src/tier-engine/web/client-plugin.tsx +27 -0
  32. package/src/tier-engine/web/index.ts +8 -0
  33. package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
  34. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  35. package/src/user-data-rights/deletion-token.ts +9 -3
  36. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
  37. package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
  38. package/src/user-profile/i18n.ts +2 -3
  39. package/src/user-profile/web/profile-screen.tsx +29 -5
@@ -447,3 +447,108 @@ describe("scenario 5: runtime-secret resolution", () => {
447
447
  expect(res.status).toBe(401);
448
448
  });
449
449
  });
450
+
451
+ // =============================================================================
452
+ // Scenario 6: billing-live-Gate (#104) durch den vollen Stack.
453
+ //
454
+ // Die #104-Invariante (kein Live-Checkout solange billing-live nicht true) war
455
+ // bislang nur mit gestubbter ctx.config getestet (runtime.test.ts) — kein Test
456
+ // fuhr die Kette factory → r.config → ctx.config(handle) real durch. Eigener
457
+ // Stack OHNE api-key/webhook-secret-Fallback, damit das Gate-Öffnen hermetisch
458
+ // als UnconfiguredError sichtbar wird (api-key fehlt) statt in einem echten
459
+ // Stripe-Netzwerk-Call. Ein reiner default-off-Beweis genügt nicht: er kann
460
+ // "korrektes Handle, Wert fehlt" nicht von "falsches Handle, immer undefined"
461
+ // trennen (beide → feature_disabled). Erst der positive Fall (billing-live via
462
+ // config:write:set auf dem kanonischen QN setzen → Gate öffnet) beweist die
463
+ // Handle-Resolution real.
464
+ // =============================================================================
465
+
466
+ const BILLING_LIVE_CONFIG_QN = "subscription-stripe:config:billing-live";
467
+
468
+ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
469
+ let gateStack: TestStack;
470
+
471
+ beforeAll(async () => {
472
+ const stripeFeature = createSubscriptionStripeFeature({ priceToTier: PRICE_TO_TIER });
473
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
474
+ const resolver = createConfigResolver({ encryption });
475
+ const masterKeyProvider = createEnvMasterKeyProvider({
476
+ env: {
477
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
478
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
479
+ },
480
+ });
481
+ gateStack = await setupTestStack({
482
+ features: [
483
+ createConfigFeature(),
484
+ createSecretsFeature(),
485
+ billingFoundationFeature,
486
+ stripeFeature,
487
+ ],
488
+ masterKeyProvider,
489
+ extraContext: ({ db: ctxDb, registry }) => ({
490
+ configResolver: resolver,
491
+ configEncryption: encryption,
492
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
493
+ secrets: createSecretsContext({ db: ctxDb, masterKeyProvider }),
494
+ }),
495
+ });
496
+ await createEventsTable(gateStack.db);
497
+ await unsafePushTables(gateStack.db, {
498
+ configValuesTable,
499
+ tenant_secrets: tenantSecretsTable,
500
+ });
501
+ });
502
+
503
+ afterAll(async () => {
504
+ await gateStack.cleanup();
505
+ });
506
+
507
+ const checkoutPayload = {
508
+ providerName: "stripe",
509
+ priceId: "price_pro_monthly",
510
+ successUrl: "https://app.example.com/ok",
511
+ cancelUrl: "https://app.example.com/cancel",
512
+ };
513
+
514
+ test("default-off → feature_disabled; config-flip → Gate öffnet (Handle-Resolution real)", async () => {
515
+ const tenantAdmin = createTestUser({
516
+ id: 6001,
517
+ tenantId: testTenantId(6001),
518
+ roles: ["TenantAdmin"],
519
+ });
520
+
521
+ // billing-live ungesetzt → Gate zu, throw VOR jedem api-key/Stripe-Schritt.
522
+ const closed = await gateStack.http.writeErr(
523
+ "billing-foundation:write:create-checkout-session",
524
+ checkoutPayload,
525
+ tenantAdmin,
526
+ );
527
+ expect(closed.code).toBe("feature_disabled");
528
+
529
+ // billing-live=true auf dem kanonischen QN setzen (was der abgeleitete
530
+ // Sysadmin-configEdit-Screen in prod dispatcht).
531
+ const sysAdmin = createTestUser({
532
+ id: 6002,
533
+ tenantId: SYSTEM_TENANT_ID,
534
+ roles: ["SystemAdmin"],
535
+ });
536
+ await gateStack.http.writeOk(
537
+ "config:write:set",
538
+ { key: BILLING_LIVE_CONFIG_QN, value: true, scope: "system" },
539
+ sysAdmin,
540
+ );
541
+
542
+ // Gate jetzt offen: nicht mehr feature_disabled. Der nächste Schritt
543
+ // (api-key-Resolution) schlägt fehl, weil weder secret noch fallback
544
+ // gesetzt sind → unconfigured. Wäre der billing-live-Handle falsch
545
+ // qualifiziert, bliebe ctx.config undefined → Fehler weiter feature_disabled.
546
+ const opened = await gateStack.http.writeErr(
547
+ "billing-foundation:write:create-checkout-session",
548
+ checkoutPayload,
549
+ tenantAdmin,
550
+ );
551
+ expect(opened.code).not.toBe("feature_disabled");
552
+ expect(opened.code).toBe("unconfigured");
553
+ });
554
+ });
@@ -13,7 +13,8 @@
13
13
  // wird er als config-Key. `mask` leitet den Sysadmin-configEdit-Screen
14
14
  // + Settings-Hub-Nav ab — kein handgeschriebenes r.screen/r.nav in der
15
15
  // App mehr (v2 hatte die Keys als `r.secret` + App-eigene Maske).
16
- // - `subscription-stripe:config:billingLive` **system config**
16
+ // - `subscription-stripe:config:billing-live` (shortKey `billingLive`,
17
+ // kebab-qualifiziert von r.config) → **system config**
17
18
  // (boolean, default false). Der Master-Switch: ohne ihn darf kein
18
19
  // checkout eine Stripe-Session erzeugen (#104-Invariante, write-side
19
20
  // im createCheckoutSession-Gate durchgesetzt).
@@ -29,6 +29,18 @@ function queryAccess(
29
29
  return access.roles;
30
30
  }
31
31
 
32
+ function rawWriteAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch: string): unknown {
33
+ const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
34
+ if (!entry) throw new Error(`handler ${nameMatch} not registered`);
35
+ return entry[1].access;
36
+ }
37
+
38
+ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch: string): unknown {
39
+ const entry = Object.entries(feature.queryHandlers).find(([qn]) => qn.includes(nameMatch));
40
+ if (!entry) throw new Error(`query ${nameMatch} not registered`);
41
+ return entry[1].access;
42
+ }
43
+
32
44
  describe("createTagsFeature shape", () => {
33
45
  test("registers tag + tag-assignment entities, 3 write-handlers, 2 query-handlers", () => {
34
46
  const feature = createTagsFeature();
@@ -75,6 +87,28 @@ describe("createTagsFeature access-options", () => {
75
87
  expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
76
88
  expect(queryAccess(feature, "tag-assignment:list")).toEqual(["Admin", "Editor"]);
77
89
  });
90
+
91
+ test("access:{openToAll} applies to every write- and query-path", () => {
92
+ const feature = createTagsFeature({ access: { openToAll: true } });
93
+ for (const path of ["create-tag", "assign-tag", "remove-tag"]) {
94
+ expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
95
+ }
96
+ for (const query of ["tag:list", "tag-assignment:list"]) {
97
+ expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
98
+ }
99
+ });
100
+
101
+ test("access takes precedence over the roles shorthand", () => {
102
+ const feature = createTagsFeature({ access: { openToAll: true }, roles: ["Admin"] });
103
+ expect(rawWriteAccess(feature, "create-tag")).toEqual({ openToAll: true });
104
+ expect(rawQueryAccess(feature, "tag:list")).toEqual({ openToAll: true });
105
+ });
106
+
107
+ test("access:{roles} threads through like the roles shorthand", () => {
108
+ const feature = createTagsFeature({ access: { roles: ["Owner"] } });
109
+ expect(writeAccess(feature, "remove-tag")).toEqual(["Owner"]);
110
+ expect(queryAccess(feature, "tag-assignment:list")).toEqual(["Owner"]);
111
+ });
78
112
  });
79
113
 
80
114
  describe("createTagPayloadSchema", () => {
@@ -183,3 +183,69 @@ describe("tags integration — multi-tenant isolation", () => {
183
183
  expect(await countAssignments(otherTenant.tenantId)).toBe(0);
184
184
  });
185
185
  });
186
+
187
+ // The access option must reach the runtime, not just the handler shape: a host
188
+ // that mounts tags with openToAll lets a user WITHOUT any default tag role tag
189
+ // freely (the exact failure that bit money-horse, whose signup users carry
190
+ // "Admin", not "TenantAdmin"). Default-mounted tags deny that same user.
191
+ describe("tags integration — openToAll access model", () => {
192
+ let openStack: TestStack;
193
+ // role deliberately not in DEFAULT_TAG_ROLES nor "Admin" — proves openToAll,
194
+ // not an accidental role match.
195
+ const unprivileged = createTestUser({ roles: ["Viewer"] });
196
+
197
+ beforeAll(async () => {
198
+ openStack = await setupTestStack({
199
+ features: [createTagsFeature({ access: { openToAll: true } })],
200
+ });
201
+ await unsafeCreateEntityTable(openStack.db, tagEntity);
202
+ await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
203
+ await createEventsTable(openStack.db);
204
+ });
205
+
206
+ afterAll(async () => {
207
+ await openStack.cleanup();
208
+ });
209
+
210
+ test("a non-tag-role user can create, assign, list and remove", async () => {
211
+ const tag = await openStack.http.writeOk<{ id: string }>(
212
+ TagsHandlers.createTag,
213
+ { name: "Paket A" },
214
+ unprivileged,
215
+ );
216
+ await openStack.http.writeOk(
217
+ TagsHandlers.assignTag,
218
+ { tagId: tag.id, entityType: "credit", entityId: "c-1" },
219
+ unprivileged,
220
+ );
221
+
222
+ const tags = await openStack.http.queryOk<{ rows: unknown[] }>(
223
+ TagsQueries.tagList,
224
+ {},
225
+ unprivileged,
226
+ );
227
+ expect(tags.rows).toHaveLength(1);
228
+
229
+ const assigned = await openStack.http.queryOk<{ rows: unknown[] }>(
230
+ TagsQueries.assignmentList,
231
+ { filter: { field: "entityId", op: "eq", value: "c-1" } },
232
+ unprivileged,
233
+ );
234
+ expect(assigned.rows).toHaveLength(1);
235
+
236
+ await openStack.http.writeOk(
237
+ TagsHandlers.removeTag,
238
+ { tagId: tag.id, entityType: "credit", entityId: "c-1" },
239
+ unprivileged,
240
+ );
241
+ });
242
+
243
+ test("the SAME user is denied on a default-role-mounted feature", async () => {
244
+ const denied = await stack.http.writeErr(
245
+ TagsHandlers.createTag,
246
+ { name: "nope" },
247
+ unprivileged,
248
+ );
249
+ expect(denied.httpStatus).toBe(403);
250
+ });
251
+ });
@@ -4,6 +4,8 @@
4
4
  // Spec: kumiko-platform/docs/plans/features/tags.md
5
5
  // C#1 design: money-horse/docs/plans/cashcolt-vertragspakete.md
6
6
 
7
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
8
+
7
9
  export const TAGS_FEATURE_NAME = "tags";
8
10
 
9
11
  // Qualified handler names (QN format: scope:type:name). Clients reference the
@@ -23,6 +25,13 @@ export const TagsQueries = {
23
25
 
24
26
  // Default RBAC for every tag write/read path. Tags are a low-sensitivity
25
27
  // collaboration tool, so both tenant roles may use them. Apps with their own
26
- // role vocabulary (e.g. "Admin"/"Editor") override via createTagsFeature({ roles })
27
- // otherwise the hard-wired QNs are access_denied for their users.
28
+ // role vocabulary (e.g. "Admin"/"Editor") override via createTagsFeature({ roles }),
29
+ // or adopt the host's whole access model with createTagsFeature({ access }) —
30
+ // otherwise the hard-wired QNs are access_denied for their users.
28
31
  export const DEFAULT_TAG_ROLES = ["TenantAdmin", "TenantMember"] as const;
32
+
33
+ // The default access rule applied to every tag handler when the app passes
34
+ // neither `access` nor `roles`. createTagsFeature({ access: { openToAll: true } })
35
+ // makes tagging reachable for any authenticated tenant user — matching apps
36
+ // whose other handlers are openToAll rather than role-gated.
37
+ export const DEFAULT_TAG_ACCESS: AccessRule = { roles: DEFAULT_TAG_ROLES };
@@ -17,54 +17,59 @@
17
17
  // (`wireTagsFor`), search indexing, user-data-rights anonymization.
18
18
 
19
19
  import {
20
+ type AccessRule,
20
21
  defineEntityListHandler,
21
22
  defineFeature,
22
23
  type FeatureRegistrar,
23
24
  } from "@cosmicdrift/kumiko-framework/engine";
24
- import { DEFAULT_TAG_ROLES, TAGS_FEATURE_NAME } from "./constants";
25
+ import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
25
26
  import { tagAssignmentEntity, tagEntity } from "./entity";
26
27
  import { createAssignTagHandler } from "./handlers/assign-tag.write";
27
28
  import { createCreateTagHandler } from "./handlers/create-tag.write";
28
29
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
29
30
 
30
- function registerTags(
31
- r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>,
32
- roles: readonly string[],
33
- ): void {
31
+ function registerTags(r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>, access: AccessRule): void {
34
32
  r.describe(
35
- "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) 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. Provides write-handlers `create-tag`, `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. Override the default tenant roles with createTagsFeature({ roles }).",
33
+ "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) 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. Provides write-handlers `create-tag`, `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. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }).",
36
34
  );
37
35
 
38
36
  r.entity("tag", tagEntity);
39
37
  r.entity("tag-assignment", tagAssignmentEntity);
40
38
 
41
- r.writeHandler(createCreateTagHandler(roles));
42
- r.writeHandler(createAssignTagHandler(roles));
43
- r.writeHandler(createRemoveTagHandler(roles));
39
+ r.writeHandler(createCreateTagHandler(access));
40
+ r.writeHandler(createAssignTagHandler(access));
41
+ r.writeHandler(createRemoveTagHandler(access));
44
42
 
45
- r.queryHandler(defineEntityListHandler("tag", tagEntity, { access: { roles } }));
46
- r.queryHandler(
47
- defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access: { roles } }),
48
- );
43
+ r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
44
+ r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
49
45
  }
50
46
 
51
47
  export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
52
- registerTags(r, DEFAULT_TAG_ROLES),
48
+ registerTags(r, DEFAULT_TAG_ACCESS),
53
49
  );
54
50
 
55
51
  export type TagsFeatureOptions = {
56
- /** RBAC roles for all tag write/read paths. Default ["TenantAdmin","TenantMember"].
57
- * Apps with their own role vocabulary (e.g. ["Admin","Editor"]) MUST set this,
58
- * else the hard-wired tag QNs are access_denied for their users. */
52
+ /** Access rule for all tag write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
53
+ * Adopt the host's model e.g. { openToAll: true } when the host lets any
54
+ * authenticated tenant user tag (like the rest of its handlers), or
55
+ * { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
56
+ readonly access?: AccessRule;
57
+ /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
59
58
  readonly roles?: readonly string[];
60
59
  };
61
60
 
61
+ function resolveAccess(opts: TagsFeatureOptions): AccessRule {
62
+ if (opts.access !== undefined) return opts.access;
63
+ if (opts.roles !== undefined) return { roles: opts.roles };
64
+ return DEFAULT_TAG_ACCESS;
65
+ }
66
+
62
67
  // Backwards-compat / options wrapper. Without options returns the module-level
63
- // singleton (no rebuild). A custom roles list builds a fresh feature-definition.
68
+ // singleton (no rebuild). access/roles build a fresh feature-definition.
64
69
  export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFeature {
65
- if (opts.roles === undefined) {
70
+ if (opts.access === undefined && opts.roles === undefined) {
66
71
  return tagsFeature;
67
72
  }
68
- const roles = opts.roles;
69
- return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, roles));
73
+ const access = resolveAccess(opts);
74
+ return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, access));
70
75
  }
@@ -1,6 +1,6 @@
1
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { tagAssignmentAggregateId } from "../aggregate-id";
3
- import { DEFAULT_TAG_ROLES } from "../constants";
3
+ import { DEFAULT_TAG_ACCESS } from "../constants";
4
4
  import { tagAssignmentExecutor } from "../executor";
5
5
  import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
6
6
 
@@ -12,13 +12,11 @@ import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
12
12
  // success when the assignment is already present (the requested end state). A
13
13
  // concurrent first-time race still version_conflicts (409); acceptable, since
14
14
  // assigning is a low-frequency UI action.
15
- export function createAssignTagHandler(
16
- roles: readonly string[] = DEFAULT_TAG_ROLES,
17
- ): WriteHandlerDef {
15
+ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
18
16
  return {
19
17
  name: "assign-tag",
20
18
  schema: assignTagPayloadSchema,
21
- access: { roles },
19
+ access,
22
20
  handler: async (event, ctx) => {
23
21
  const payload = event.payload as AssignTagPayload; // @cast-boundary engine-payload
24
22
  const id = tagAssignmentAggregateId(
@@ -1,5 +1,5 @@
1
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { DEFAULT_TAG_ROLES } from "../constants";
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_TAG_ACCESS } from "../constants";
3
3
  import { tagExecutor } from "../executor";
4
4
  import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
5
5
 
@@ -8,13 +8,11 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
8
8
  // catalog is a free list and dedup is a UI concern (autocomplete from existing
9
9
  // tags). Rename/delete are deferred to a later iteration (v1 scope: create,
10
10
  // assign, remove, list).
11
- export function createCreateTagHandler(
12
- roles: readonly string[] = DEFAULT_TAG_ROLES,
13
- ): WriteHandlerDef {
11
+ export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
14
12
  return {
15
13
  name: "create-tag",
16
14
  schema: createTagPayloadSchema,
17
- access: { roles },
15
+ access,
18
16
  handler: async (event, ctx) => {
19
17
  const payload = event.payload as CreateTagPayload; // @cast-boundary engine-payload
20
18
  return tagExecutor.create(payload, event.user, ctx.db);
@@ -1,19 +1,17 @@
1
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { tagAssignmentAggregateId } from "../aggregate-id";
3
- import { DEFAULT_TAG_ROLES } from "../constants";
3
+ import { DEFAULT_TAG_ACCESS } from "../constants";
4
4
  import { tagAssignmentExecutor } from "../executor";
5
5
  import { type RemoveTagPayload, removeTagPayloadSchema } from "../schemas";
6
6
 
7
7
  // remove-tag — unlinks a tag from a host entity. Idempotent: removing an
8
8
  // assignment that doesn't exist is already the requested end state (not
9
9
  // assigned), so we pre-check and return success without a delete.
10
- export function createRemoveTagHandler(
11
- roles: readonly string[] = DEFAULT_TAG_ROLES,
12
- ): WriteHandlerDef {
10
+ export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
13
11
  return {
14
12
  name: "remove-tag",
15
13
  schema: removeTagPayloadSchema,
16
- access: { roles },
14
+ access,
17
15
  handler: async (event, ctx) => {
18
16
  const payload = event.payload as RemoveTagPayload; // @cast-boundary engine-payload
19
17
  const id = tagAssignmentAggregateId(
package/src/tags/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { tagAssignmentAggregateId } from "./aggregate-id";
2
2
  export {
3
+ DEFAULT_TAG_ACCESS,
3
4
  DEFAULT_TAG_ROLES,
4
5
  TAGS_FEATURE_NAME,
5
6
  TagsHandlers,
@@ -18,6 +18,10 @@ describe("tier-engine drift pins", () => {
18
18
  expect(TierEngineHandlers.update).toBe("tier-engine:write:tier-assignment:update");
19
19
  expect(TierEngineQueries.list).toBe("tier-engine:query:tier-assignment:list");
20
20
  expect(TierEngineQueries.getActiveTier).toBe("tier-engine:query:get-active-tier");
21
+ // Screen↔Handler-Contract: der TierAdminScreen dispatcht exakt diese QNs.
22
+ expect(TierEngineHandlers.setTenantTier).toBe("tier-engine:write:set-tenant-tier");
23
+ expect(TierEngineQueries.getTenantTier).toBe("tier-engine:query:get-tenant-tier");
24
+ expect(TierEngineQueries.tierOptions).toBe("tier-engine:query:tier-options");
21
25
 
22
26
  // Every QN must start with the feature-name as scope.
23
27
  for (const qn of [...Object.values(TierEngineHandlers), ...Object.values(TierEngineQueries)]) {
@@ -167,6 +167,36 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
167
167
  expect(resolver(tenantA).has("feat-pro")).toBe(true);
168
168
  });
169
169
 
170
+ test("(4) set-tenant-tier reflects in resolver — effective gating, not just projection", async () => {
171
+ // Kern-Zweck von #434: ein manueller Grant muss das EFFEKTIVE Feature-Set
172
+ // ändern (Resolver-Cache), nicht nur die Projektion. set-tenant-tier
173
+ // schreibt direkt über den Executor — feuert das den postSave-Hook, der
174
+ // den Cache aktualisiert? Stale-Upgrade free→pro deckt den Fall ab, den
175
+ // der cache-miss-Fallback NICHT rettet.
176
+ const usage = findTierResolverUsage(features);
177
+ if (!usage) throw new Error("setup failure");
178
+ const plugin = usage.options as TierResolverPlugin;
179
+
180
+ const sysA = createTestUser({
181
+ id: "sys-4",
182
+ tenantId: tenantA,
183
+ roles: ["SystemAdmin", "TenantAdmin"],
184
+ });
185
+ await stack.http.writeOk("tier-engine:write:tier-assignment:create", { tier: "free" }, sysA);
186
+
187
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
188
+ expect(resolver(tenantA).has("feat-pro")).toBe(false);
189
+
190
+ // Manueller Grant via set-tenant-tier (cross-tenant-fähig, hier eigener Tenant).
191
+ await stack.http.writeOk(
192
+ "tier-engine:write:set-tenant-tier",
193
+ { tenantId: tenantA, tier: "pro" },
194
+ sysA,
195
+ );
196
+
197
+ expect(resolver(tenantA).has("feat-pro")).toBe(true);
198
+ });
199
+
170
200
  test("(3) SYSTEM_TENANT_ID returns union of all tier-features", async () => {
171
201
  const usage = findTierResolverUsage(features);
172
202
  if (!usage) throw new Error("setup failure");
@@ -279,6 +279,7 @@ describe("scenario 6: access control", () => {
279
279
  });
280
280
 
281
281
  test("query handlers carry the admin-only access rule (config-level check)", () => {
282
+ // (siehe Scenario 7 für die set-tenant-tier/get-tenant-tier Reads)
282
283
  // Read-access is enforced by the same role-rule set on the query handler.
283
284
  // We assert the rule is registered correctly — covers regression when
284
285
  // someone changes adminAccess to openToAll without noticing.
@@ -294,3 +295,120 @@ describe("scenario 6: access control", () => {
294
295
  expect(JSON.stringify(activeTierRule)).toMatch(/SystemAdmin/);
295
296
  });
296
297
  });
298
+
299
+ // --- Scenario 7: manueller cross-tenant Tier-Grant (set-tenant-tier) ---
300
+ //
301
+ // Kern-Sicherheitsgrenze von #434: ein SystemAdmin sitzt in seinem eigenen
302
+ // Tenant, setzt aber das Tier eines FREMDEN Tenants — ohne Billing-Kauf.
303
+ // Der Event muss im Stream des Ziel-Tenants landen (nicht im Admin-Tenant),
304
+ // `source: "manual"` tragen und nur für SystemAdmin erreichbar sein.
305
+
306
+ type SetTenantTierResult = { tenantId: string; tier: string; isNew: boolean };
307
+
308
+ describe("scenario 7: cross-tenant manual grant", () => {
309
+ test("SystemAdmin sets a FOREIGN tenant's tier — lands in the target stream, source=manual", async () => {
310
+ const adminTenant = testTenantId(401);
311
+ const targetTenant = testTenantId(402);
312
+ const sysadmin = createTestUser({ id: 401, tenantId: adminTenant, roles: ["SystemAdmin"] });
313
+
314
+ const result = await stack.http.writeOk<SetTenantTierResult>(
315
+ TierEngineHandlers.setTenantTier,
316
+ { tenantId: targetTenant, tier: "pro" },
317
+ sysadmin,
318
+ );
319
+ expect(result.tenantId).toBe(targetTenant);
320
+ expect(result.tier).toBe("pro");
321
+ expect(result.isNew).toBe(true);
322
+
323
+ // Beweis, dass der Event im Ziel-Stream liegt: ein Admin IM Ziel-Tenant
324
+ // liest sein eigenes get-active-tier (own-tenant-scoped) und sieht "pro".
325
+ const targetAdmin = createTestUser({ id: 402, tenantId: targetTenant, roles: ["TenantAdmin"] });
326
+ const seenByTarget = await stack.http.queryOk<Record<string, unknown> | null>(
327
+ TierEngineQueries.getActiveTier,
328
+ {},
329
+ targetAdmin,
330
+ );
331
+ expect(seenByTarget!["tier"]).toBe("pro");
332
+
333
+ // get-tenant-tier (cross-tenant Read, SystemAdmin) liefert source=manual.
334
+ const grant = await stack.http.queryOk<Record<string, unknown> | null>(
335
+ TierEngineQueries.getTenantTier,
336
+ { tenantId: targetTenant },
337
+ sysadmin,
338
+ );
339
+ expect(grant!["tier"]).toBe("pro");
340
+ expect(grant!["source"]).toBe("manual");
341
+
342
+ // Der Admin-eigene Tenant bleibt unberührt — kein Tier dort geleakt.
343
+ const seenByAdmin = await stack.http.queryOk<Record<string, unknown> | null>(
344
+ TierEngineQueries.getActiveTier,
345
+ {},
346
+ sysadmin,
347
+ );
348
+ expect(seenByAdmin).toBeNull();
349
+ });
350
+
351
+ test("upsert is idempotent — second set updates the same aggregate (isNew:false)", async () => {
352
+ const sysadmin = createTestUser({
353
+ id: 410,
354
+ tenantId: testTenantId(410),
355
+ roles: ["SystemAdmin"],
356
+ });
357
+ const target = testTenantId(411);
358
+
359
+ const first = await stack.http.writeOk<SetTenantTierResult>(
360
+ TierEngineHandlers.setTenantTier,
361
+ { tenantId: target, tier: "pro" },
362
+ sysadmin,
363
+ );
364
+ expect(first.isNew).toBe(true);
365
+
366
+ const second = await stack.http.writeOk<SetTenantTierResult>(
367
+ TierEngineHandlers.setTenantTier,
368
+ { tenantId: target, tier: "business" },
369
+ sysadmin,
370
+ );
371
+ expect(second.isNew).toBe(false);
372
+ expect(second.tier).toBe("business");
373
+
374
+ const grant = await stack.http.queryOk<Record<string, unknown> | null>(
375
+ TierEngineQueries.getTenantTier,
376
+ { tenantId: target },
377
+ sysadmin,
378
+ );
379
+ expect(grant!["tier"]).toBe("business");
380
+ });
381
+
382
+ test("TenantAdmin cannot set a foreign tenant's tier — fail-closed", async () => {
383
+ const tenantAdmin = createTestUser({
384
+ id: 420,
385
+ tenantId: testTenantId(420),
386
+ roles: ["TenantAdmin"],
387
+ });
388
+
389
+ const error = await stack.http.writeErr(
390
+ TierEngineHandlers.setTenantTier,
391
+ { tenantId: testTenantId(421), tier: "pro" },
392
+ tenantAdmin,
393
+ );
394
+ expectErrorIncludes(error, "access_denied");
395
+ });
396
+
397
+ test("normal User cannot set a tier and cannot read get-tenant-tier", async () => {
398
+ const normalUser = TestUsers.user;
399
+
400
+ const writeError = await stack.http.writeErr(
401
+ TierEngineHandlers.setTenantTier,
402
+ { tenantId: testTenantId(431), tier: "pro" },
403
+ normalUser,
404
+ );
405
+ expectErrorIncludes(writeError, "access_denied");
406
+
407
+ const res = await stack.http.query(
408
+ TierEngineQueries.getTenantTier,
409
+ { tenantId: testTenantId(431) },
410
+ normalUser,
411
+ );
412
+ expect(res.status).toBe(403);
413
+ });
414
+ });
@@ -1,3 +1,9 @@
1
+ // @runtime client
2
+ // Pure string-literal QNs/ids — von Server (feature.ts) UND Client
3
+ // (web/tier-admin-screen, client-plugin) importiert. Als `client` markiert,
4
+ // die im kumiko-Isolation-Modell permissivste Kategorie (runtime darf
5
+ // client importieren, client nur client) — sonst wirft der Runtime-
6
+ // Isolation-Guard auf den Client-Imports. Muster wie text-content/constants.
1
7
  // Feature name
2
8
  export const TIER_ENGINE_FEATURE = "tier-engine" as const;
3
9
 
@@ -6,10 +12,17 @@ export const TIER_ENGINE_FEATURE = "tier-engine" as const;
6
12
  export const TierEngineHandlers = {
7
13
  create: "tier-engine:write:tier-assignment:create",
8
14
  update: "tier-engine:write:tier-assignment:update",
15
+ setTenantTier: "tier-engine:write:set-tenant-tier",
9
16
  } as const;
10
17
 
11
18
  // Qualified query handler names.
12
19
  export const TierEngineQueries = {
13
20
  list: "tier-engine:query:tier-assignment:list",
14
21
  getActiveTier: "tier-engine:query:get-active-tier",
22
+ getTenantTier: "tier-engine:query:get-tenant-tier",
23
+ tierOptions: "tier-engine:query:tier-options",
15
24
  } as const;
25
+
26
+ // Screen-id für den manuellen Tier-Grant-Screen. Apps referenzieren ihn
27
+ // qualifiziert in r.nav: `tier-engine:screen:tier-admin`.
28
+ export const TIER_ADMIN_SCREEN_ID = "tier-admin" as const;
@@ -26,5 +26,10 @@ export const tierAssignmentEntity = createEntity({
26
26
  table: "read_tier_assignments",
27
27
  fields: {
28
28
  tier: createTextField({ required: true, maxLength: 50 }),
29
+ // Woher das Assignment stammt: "manual" (Admin-Grant via tier-admin-Screen),
30
+ // "stripe" (future Billing-Sync), "default" (auto-default-on-signup-Hook).
31
+ // Optional für Back-Compat zu bestehenden Rows ohne source. Schützt manuelle
32
+ // Grants davor, von einem späteren Stripe→Tier-Sync geplättet zu werden.
33
+ source: createTextField({ required: false, maxLength: 20 }),
29
34
  },
30
35
  });