@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.
- package/package.json +2 -1
- package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
- package/src/custom-fields/__tests__/feature.test.ts +57 -4
- package/src/custom-fields/feature.ts +19 -4
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
- package/src/files-provider-s3/s3-provider.ts +9 -3
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
- package/src/managed-pages/handlers/set.write.ts +14 -4
- package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
- package/src/subscription-stripe/feature.ts +2 -1
- package/src/tags/__tests__/feature.test.ts +34 -0
- package/src/tags/__tests__/tags.integration.test.ts +66 -0
- package/src/tags/constants.ts +11 -2
- package/src/tags/feature.ts +26 -21
- package/src/tags/handlers/assign-tag.write.ts +4 -6
- package/src/tags/handlers/create-tag.write.ts +4 -6
- package/src/tags/handlers/remove-tag.write.ts +4 -6
- package/src/tags/index.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +4 -0
- package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
- package/src/tier-engine/constants.ts +13 -0
- package/src/tier-engine/entity.ts +5 -0
- package/src/tier-engine/feature.ts +51 -3
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
- package/src/tier-engine/i18n.ts +39 -0
- package/src/tier-engine/web/client-plugin.tsx +27 -0
- package/src/tier-engine/web/index.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/deletion-token.ts +9 -3
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
- package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
- package/src/user-profile/i18n.ts +2 -3
- 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:
|
|
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
|
+
});
|
package/src/tags/constants.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 };
|
package/src/tags/feature.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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(
|
|
42
|
-
r.writeHandler(createAssignTagHandler(
|
|
43
|
-
r.writeHandler(createRemoveTagHandler(
|
|
39
|
+
r.writeHandler(createCreateTagHandler(access));
|
|
40
|
+
r.writeHandler(createAssignTagHandler(access));
|
|
41
|
+
r.writeHandler(createRemoveTagHandler(access));
|
|
44
42
|
|
|
45
|
-
r.queryHandler(defineEntityListHandler("tag", tagEntity, { access
|
|
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,
|
|
48
|
+
registerTags(r, DEFAULT_TAG_ACCESS),
|
|
53
49
|
);
|
|
54
50
|
|
|
55
51
|
export type TagsFeatureOptions = {
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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).
|
|
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
|
|
69
|
-
return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r,
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
@@ -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
|
});
|