@cosmicdrift/kumiko-bundled-features 0.224.2 → 0.226.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.
Files changed (30) hide show
  1. package/package.json +11 -9
  2. package/src/admin-shell/__tests__/admin-shell.boot.test.ts +66 -0
  3. package/src/admin-shell/feature.ts +30 -3
  4. package/src/admin-shell/i18n.ts +2 -0
  5. package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +207 -0
  6. package/src/cap-overview/__tests__/cap-overview.boot.test.ts +120 -0
  7. package/src/cap-overview/__tests__/usage-math.test.ts +60 -0
  8. package/src/cap-overview/changes.json +8 -0
  9. package/src/cap-overview/constants.ts +26 -0
  10. package/src/cap-overview/feature.ts +77 -0
  11. package/src/cap-overview/handlers/caps-usage.query.ts +99 -0
  12. package/src/cap-overview/handlers/tenant-caps-list.query.ts +230 -0
  13. package/src/cap-overview/handlers/tenant-options.query.ts +33 -0
  14. package/src/cap-overview/i18n.ts +26 -0
  15. package/src/cap-overview/index.ts +20 -0
  16. package/src/cap-overview/screens.ts +101 -0
  17. package/src/cap-overview/types.ts +58 -0
  18. package/src/cap-overview/usage-math.ts +32 -0
  19. package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +244 -0
  20. package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +76 -0
  21. package/src/cap-overview/web/cap-cards-panel.tsx +109 -0
  22. package/src/cap-overview/web/cap-usage-bar.tsx +47 -0
  23. package/src/cap-overview/web/cap-usage-cell.tsx +20 -0
  24. package/src/cap-overview/web/client-plugin.tsx +27 -0
  25. package/src/cap-overview/web/index.ts +10 -0
  26. package/src/form-draft/__tests__/cleanup.integration.test.ts +51 -20
  27. package/src/notes-history/__tests__/notes-section.test.tsx +22 -11
  28. package/src/notes-history/web/notes-section.tsx +2 -2
  29. package/src/sessions/handlers/detail.query.ts +11 -0
  30. package/src/sessions/handlers/list.query.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.224.2",
3
+ "version": "0.226.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>",
@@ -36,6 +36,8 @@
36
36
  "./tier-engine": "./src/tier-engine/index.ts",
37
37
  "./tier-engine/web": "./src/tier-engine/web/index.ts",
38
38
  "./cap-counter": "./src/cap-counter/index.ts",
39
+ "./cap-overview": "./src/cap-overview/index.ts",
40
+ "./cap-overview/web": "./src/cap-overview/web/index.ts",
39
41
  "./custom-fields": "./src/custom-fields/index.ts",
40
42
  "./custom-fields/web": "./src/custom-fields/web/index.ts",
41
43
  "./tags": "./src/tags/index.ts",
@@ -128,12 +130,12 @@
128
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
129
131
  },
130
132
  "dependencies": {
131
- "@cosmicdrift/kumiko-dispatcher-live": "0.224.2",
132
- "@cosmicdrift/kumiko-framework": "0.224.2",
133
- "@cosmicdrift/kumiko-headless": "0.224.2",
134
- "@cosmicdrift/kumiko-renderer": "0.224.2",
135
- "@cosmicdrift/kumiko-renderer-web": "0.224.2",
136
- "@cosmicdrift/kumiko-types": "0.224.2",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.226.0",
134
+ "@cosmicdrift/kumiko-framework": "0.226.0",
135
+ "@cosmicdrift/kumiko-headless": "0.226.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.226.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.226.0",
138
+ "@cosmicdrift/kumiko-types": "0.226.0",
137
139
  "@mollie/api-client": "^4.5.0",
138
140
  "@node-rs/argon2": "^2.0.2",
139
141
  "@types/mailparser": "^3.4.6",
@@ -162,7 +164,7 @@
162
164
  "devDependencies": {
163
165
  "@testing-library/user-event": "^14.6.1",
164
166
  "@types/qrcode": "^1.5.5",
165
- "@cosmicdrift/kumiko-locale-de": "0.224.2",
166
- "@cosmicdrift/kumiko-locale-es": "0.224.2"
167
+ "@cosmicdrift/kumiko-locale-de": "0.226.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.226.0"
167
169
  }
168
170
  }
@@ -1,9 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { access, createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { createAuditFeature } from "../../audit/feature";
4
+ import { billingFoundationFeature } from "../../billing-foundation";
5
+ import { createCapOverviewFeature } from "../../cap-overview/feature";
6
+ import type { CapSpec } from "../../cap-overview/types";
7
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
4
8
  import { createConfigFeature } from "../../config/feature";
5
9
  import { createJobsFeature } from "../../jobs/feature";
6
10
  import { createTenantFeature } from "../../tenant/feature";
11
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
7
12
  import { tierEngineFeature } from "../../tier-engine/feature";
8
13
  import { createUserFeature } from "../../user/feature";
9
14
  import {
@@ -23,6 +28,13 @@ const features = [
23
28
  createAdminShellFeature(),
24
29
  ];
25
30
 
31
+ const testCap: CapSpec = {
32
+ id: "widgets",
33
+ label: "test.cap.widgets",
34
+ limit: () => 5,
35
+ usage: async () => 0,
36
+ };
37
+
26
38
  describe("admin-shell boot + workspace composition", () => {
27
39
  test("validateBoot with user, tenant, audit, jobs, tier-engine", () => {
28
40
  expect(() => validateBoot(features)).not.toThrow();
@@ -144,4 +156,58 @@ describe("admin-shell boot + workspace composition", () => {
144
156
  expect(shell.translations?.["admin-shell:nav.tenantOverview"]?.["en"]).toBe("Overview");
145
157
  expect(shell.translations?.["screen:tenant-overview.title"]?.["en"]).toBe("Overview");
146
158
  });
159
+
160
+ test("includeCapOverview:true adds cap-overview nav to both workspaces", () => {
161
+ const withCapOverview = createAdminShellFeature({ includeCapOverview: true });
162
+ const registry = createRegistry([
163
+ createConfigFeature(),
164
+ createUserFeature(),
165
+ createTenantFeature(),
166
+ createAuditFeature(),
167
+ createJobsFeature(),
168
+ tierEngineFeature,
169
+ createComplianceProfilesFeature(),
170
+ createTenantLifecycleFeature(),
171
+ billingFoundationFeature,
172
+ createCapOverviewFeature({ caps: [testCap] }),
173
+ withCapOverview,
174
+ ]);
175
+ expect(
176
+ registry.getWorkspaceNavs(`${ADMIN_SHELL_FEATURE}:workspace:${DEFAULT_TENANT_WORKSPACE_ID}`),
177
+ ).toEqual([
178
+ "admin-shell:nav:tenant-overview",
179
+ "tenant:nav:members",
180
+ "audit:nav:audit-log",
181
+ "admin-shell:nav:my-caps",
182
+ ]);
183
+ expect(
184
+ registry.getWorkspaceNavs(
185
+ `${ADMIN_SHELL_FEATURE}:workspace:${DEFAULT_PLATFORM_WORKSPACE_ID}`,
186
+ ),
187
+ ).toEqual([
188
+ "admin-shell:nav:platform-overview",
189
+ "admin-shell:nav:tenants",
190
+ "admin-shell:nav:tenant-caps",
191
+ "jobs:nav:job-runs",
192
+ "admin-shell:nav:tier-admin",
193
+ ]);
194
+ expect(registry.getNav("admin-shell:nav:my-caps")?.screen).toBe("cap-overview:screen:my-caps");
195
+ expect(registry.getNav("admin-shell:nav:tenant-caps")?.screen).toBe(
196
+ "cap-overview:screen:tenant-cap-list",
197
+ );
198
+ });
199
+
200
+ test("includeCapOverview default (false) adds no cap-overview nav entries", () => {
201
+ const registry = createRegistry(features);
202
+ expect(
203
+ registry.getWorkspaceNavs(`${ADMIN_SHELL_FEATURE}:workspace:${DEFAULT_TENANT_WORKSPACE_ID}`),
204
+ ).not.toContain("admin-shell:nav:my-caps");
205
+ expect(
206
+ registry.getWorkspaceNavs(
207
+ `${ADMIN_SHELL_FEATURE}:workspace:${DEFAULT_PLATFORM_WORKSPACE_ID}`,
208
+ ),
209
+ ).not.toContain("admin-shell:nav:tenant-caps");
210
+ expect(registry.getNav("admin-shell:nav:my-caps")).toBeUndefined();
211
+ expect(registry.getNav("admin-shell:nav:tenant-caps")).toBeUndefined();
212
+ });
147
213
  });
@@ -24,6 +24,10 @@ export type CreateAdminShellOptions = {
24
24
  };
25
25
  /** Platform nav → tier-engine:screen:tier-admin. Requires tier-engine mounted. Default true. */
26
26
  readonly includeTierAdmin?: boolean;
27
+ /** Tenant nav → cap-overview:screen:my-caps, platform nav → cap-overview:screen:tenant-cap-list.
28
+ * Requires cap-overview mounted. Default false — apps that don't mount cap-overview
29
+ * must not get a nav entry pointing at a screen that doesn't exist. */
30
+ readonly includeCapOverview?: boolean;
27
31
  /** When false, only overview screens + nav are registered — app owns r.workspace(). Default true. */
28
32
  readonly registerWorkspaces?: boolean;
29
33
  };
@@ -32,11 +36,12 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
32
36
  const tenantWsId = options.workspaceIds?.tenant ?? DEFAULT_TENANT_WORKSPACE_ID;
33
37
  const platformWsId = options.workspaceIds?.platform ?? DEFAULT_PLATFORM_WORKSPACE_ID;
34
38
  const includeTierAdmin = options.includeTierAdmin ?? true;
39
+ const includeCapOverview = options.includeCapOverview ?? false;
35
40
  const registerWorkspaces = options.registerWorkspaces ?? true;
36
41
 
37
42
  return defineFeature(ADMIN_SHELL_FEATURE, (r) => {
38
43
  r.describe(
39
- "Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`). Mount after user, tenant, audit, and jobs; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
44
+ "Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`). Mount after user, tenant, audit, and jobs; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
40
45
  );
41
46
  r.uiHints({
42
47
  displayLabel: "Admin Shell",
@@ -48,17 +53,20 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
48
53
  r.requires("audit");
49
54
  r.requires("jobs");
50
55
  if (includeTierAdmin) r.requires("tier-engine");
56
+ if (includeCapOverview) r.requires("cap-overview");
51
57
 
52
58
  r.translations({ keys: ADMIN_SHELL_I18N });
53
59
 
54
- const tenantNav = [
60
+ const tenantNav: string[] = [
55
61
  "admin-shell:nav:tenant-overview",
56
62
  "tenant:nav:members",
57
63
  "audit:nav:audit-log",
58
- ] as const;
64
+ ...(includeCapOverview ? (["admin-shell:nav:my-caps"] as const) : []),
65
+ ];
59
66
  const platformNav: string[] = [
60
67
  "admin-shell:nav:platform-overview",
61
68
  "admin-shell:nav:tenants",
69
+ ...(includeCapOverview ? (["admin-shell:nav:tenant-caps"] as const) : []),
62
70
  "jobs:nav:job-runs",
63
71
  ...(includeTierAdmin ? (["admin-shell:nav:tier-admin"] as const) : []),
64
72
  ];
@@ -113,6 +121,25 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
113
121
  });
114
122
  }
115
123
 
124
+ if (includeCapOverview) {
125
+ r.nav({
126
+ id: "my-caps",
127
+ label: "admin-shell:nav.myCaps",
128
+ icon: "gauge",
129
+ screen: "cap-overview:screen:my-caps",
130
+ access: { roles: access.admin },
131
+ order: 20,
132
+ });
133
+ r.nav({
134
+ id: "tenant-caps",
135
+ label: "admin-shell:nav.tenantCaps",
136
+ icon: "gauge",
137
+ screen: "cap-overview:screen:tenant-cap-list",
138
+ access: { roles: access.systemAdmin },
139
+ order: 20,
140
+ });
141
+ }
142
+
116
143
  if (registerWorkspaces) {
117
144
  r.workspace({
118
145
  id: tenantWsId,
@@ -13,6 +13,8 @@ export const ADMIN_SHELL_I18N: Readonly<Record<string, LocalizedString>> = {
13
13
  "admin-shell:nav.platformOverview": { en: "Overview" },
14
14
  "admin-shell:nav.tenants": { en: "Tenants" },
15
15
  "admin-shell:nav.tierAdmin": { en: "Assign tier" },
16
+ "admin-shell:nav.myCaps": { en: "Plans & Caps" },
17
+ "admin-shell:nav.tenantCaps": { en: "Plans & Caps" },
16
18
  "admin-shell:overview.tenantTitle": { en: "Administration" },
17
19
  "admin-shell:overview.platformTitle": { en: "Platform" },
18
20
  "admin-shell:overview.loading": { en: "Loading…" },
@@ -0,0 +1,207 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import {
6
+ createTestUser,
7
+ setupTestStack,
8
+ type TestStack,
9
+ testTenantId,
10
+ unsafeCreateEntityTable,
11
+ } from "@cosmicdrift/kumiko-framework/stack";
12
+ import { billingFoundationFeature } from "../../billing-foundation";
13
+ import {
14
+ createComplianceProfilesFeature,
15
+ tenantComplianceProfileEntity,
16
+ } from "../../compliance-profiles";
17
+ import { createConfigFeature } from "../../config";
18
+ import { createTenantFeature } from "../../tenant/feature";
19
+ import { tenantEntity } from "../../tenant/schema/tenant";
20
+ import { seedTenant } from "../../tenant/seeding";
21
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
22
+ import { TierEngineHandlers, tierAssignmentEntity, tierEngineFeature } from "../../tier-engine";
23
+ import { CapOverviewQueries } from "../constants";
24
+ import { createCapOverviewFeature } from "../feature";
25
+ import type { CapSpec } from "../types";
26
+
27
+ // Two tenants with distinct usage numbers — proves reads are scoped by
28
+ // tenantId, not "whichever row comes back first" or a summed total.
29
+ const TENANT_A = testTenantId(9001);
30
+ const TENANT_B = testTenantId(9002);
31
+ // Distinct tier from A/B — lets sort=tier be told apart from the sort=name default.
32
+ const TENANT_C = testTenantId(9003);
33
+ const USAGE_BY_TENANT: Readonly<Record<string, number>> = {
34
+ [TENANT_A]: 3,
35
+ [TENANT_B]: 7,
36
+ };
37
+
38
+ const testCap: CapSpec = {
39
+ id: "widgets",
40
+ label: "test.cap.widgets",
41
+ limit: () => 10,
42
+ usage: async (_db, tenantId) => USAGE_BY_TENANT[tenantId] ?? 0,
43
+ };
44
+
45
+ // Never wired up for any tenant — proves the "not measured" state renders
46
+ // as null, not 0, all the way through the real query handler.
47
+ const unmeasuredCap: CapSpec = {
48
+ id: "unmeasured",
49
+ label: "test.cap.unmeasured",
50
+ limit: () => 10,
51
+ usage: async () => null,
52
+ };
53
+
54
+ let stack: TestStack;
55
+ let db: DbConnection;
56
+
57
+ beforeAll(async () => {
58
+ stack = await setupTestStack({
59
+ features: [
60
+ createConfigFeature(),
61
+ createTenantFeature(),
62
+ createComplianceProfilesFeature(),
63
+ createTenantLifecycleFeature(),
64
+ billingFoundationFeature,
65
+ tierEngineFeature,
66
+ createCapOverviewFeature({ caps: [testCap, unmeasuredCap] }),
67
+ ],
68
+ });
69
+ db = stack.db;
70
+ await createEventsTable(db);
71
+ await unsafeCreateEntityTable(db, tenantEntity);
72
+ await unsafeCreateEntityTable(db, tierAssignmentEntity);
73
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
74
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
75
+
76
+ await seedTenant(db, { id: TENANT_A, key: `cap-overview-a-${TENANT_A}`, name: "Tenant A" });
77
+ await seedTenant(db, { id: TENANT_B, key: `cap-overview-b-${TENANT_B}`, name: "Tenant B" });
78
+ await seedTenant(db, { id: TENANT_C, key: `cap-overview-c-${TENANT_C}`, name: "Tenant C" });
79
+
80
+ const ownerA = createTestUser({
81
+ id: 90011,
82
+ tenantId: TENANT_A,
83
+ roles: ["TenantAdmin", "SystemAdmin"],
84
+ });
85
+ const ownerB = createTestUser({
86
+ id: 90021,
87
+ tenantId: TENANT_B,
88
+ roles: ["TenantAdmin", "SystemAdmin"],
89
+ });
90
+ const ownerC = createTestUser({
91
+ id: 90031,
92
+ tenantId: TENANT_C,
93
+ roles: ["TenantAdmin", "SystemAdmin"],
94
+ });
95
+ await stack.http.writeOk(TierEngineHandlers.create, { tier: "pro" }, ownerA);
96
+ await stack.http.writeOk(TierEngineHandlers.create, { tier: "pro" }, ownerB);
97
+ await stack.http.writeOk(TierEngineHandlers.create, { tier: "starter" }, ownerC);
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await stack.cleanup();
102
+ });
103
+
104
+ describe("cap-overview tenant isolation", () => {
105
+ test("TenantAdmin(A) cannot read tenant B's usage via override", async () => {
106
+ const tenantAdminA = createTestUser({ id: 90012, tenantId: TENANT_A, roles: ["TenantAdmin"] });
107
+ const res = await stack.http.query(
108
+ CapOverviewQueries.capsUsage,
109
+ { tenantId: TENANT_B },
110
+ tenantAdminA,
111
+ );
112
+ expect(res.status).toBe(403);
113
+ });
114
+
115
+ test("TenantAdmin(A) cannot list all tenants", async () => {
116
+ const tenantAdminA = createTestUser({ id: 90013, tenantId: TENANT_A, roles: ["TenantAdmin"] });
117
+ const res = await stack.http.query(CapOverviewQueries.tenantCapsList, {}, tenantAdminA);
118
+ expect(res.status).toBe(403);
119
+ });
120
+
121
+ test("TenantAdmin(A) without override sees exactly A's own numbers, not a sum", async () => {
122
+ const tenantAdminA = createTestUser({ id: 90014, tenantId: TENANT_A, roles: ["TenantAdmin"] });
123
+ const result = await stack.http.queryOk<{
124
+ rows: readonly { id: string; used: number | null; percent: number | null }[];
125
+ }>(CapOverviewQueries.capsUsage, {}, tenantAdminA);
126
+ expect(result.rows).toHaveLength(2);
127
+ const widgetsRow = result.rows.find((row) => row.id === testCap.id);
128
+ expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_A]);
129
+
130
+ const unmeasuredRow = result.rows.find((row) => row.id === unmeasuredCap.id);
131
+ expect(unmeasuredRow?.used).toBeNull();
132
+ expect(unmeasuredRow?.percent).toBeNull();
133
+ });
134
+
135
+ test("SystemAdmin can read tenant B's usage via override", async () => {
136
+ const sysAdmin = createTestUser({ id: 90015, tenantId: TENANT_A, roles: ["SystemAdmin"] });
137
+ const result = await stack.http.queryOk<{
138
+ rows: readonly { id: string; used: number | null }[];
139
+ }>(CapOverviewQueries.capsUsage, { tenantId: TENANT_B }, sysAdmin);
140
+ const widgetsRow = result.rows.find((row) => row.id === testCap.id);
141
+ expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_B]);
142
+ });
143
+
144
+ test("SystemAdmin sees both tenants on the list, each with its own numbers", async () => {
145
+ const sysAdmin = createTestUser({ id: 90016, tenantId: TENANT_A, roles: ["SystemAdmin"] });
146
+ const result = await stack.http.queryOk<{
147
+ rows: readonly Record<string, unknown>[];
148
+ }>(CapOverviewQueries.tenantCapsList, {}, sysAdmin);
149
+
150
+ const byTenant = new Map(result.rows.map((row) => [row["tenantId"] as string, row]));
151
+ const capField = `cap_${testCap.id}`;
152
+ expect((byTenant.get(TENANT_A)?.[capField] as { used: number } | undefined)?.used).toBe(
153
+ USAGE_BY_TENANT[TENANT_A],
154
+ );
155
+ expect((byTenant.get(TENANT_B)?.[capField] as { used: number } | undefined)?.used).toBe(
156
+ USAGE_BY_TENANT[TENANT_B],
157
+ );
158
+ });
159
+ });
160
+
161
+ describe("cap-overview tier filter operators", () => {
162
+ test("op:ne excludes tenants matching the value, not just those matching it", async () => {
163
+ const sysAdmin = createTestUser({ id: 90017, tenantId: TENANT_A, roles: ["SystemAdmin"] });
164
+ const result = await stack.http.queryOk<{ rows: readonly { tenantId: string }[] }>(
165
+ CapOverviewQueries.tenantCapsList,
166
+ { filters: [{ field: "tier", op: "ne", value: "pro" }] },
167
+ sysAdmin,
168
+ );
169
+ const tenantIds = result.rows.map((row) => row.tenantId);
170
+ expect(tenantIds).not.toContain(TENANT_A);
171
+ expect(tenantIds).not.toContain(TENANT_B);
172
+ expect(tenantIds).toContain(TENANT_C);
173
+ });
174
+
175
+ test("op:lt on tier is rejected, not silently treated as no filter", async () => {
176
+ const sysAdmin = createTestUser({ id: 90018, tenantId: TENANT_A, roles: ["SystemAdmin"] });
177
+ const res = await stack.http.query(
178
+ CapOverviewQueries.tenantCapsList,
179
+ { filters: [{ field: "tier", op: "lt", value: "pro" }] },
180
+ sysAdmin,
181
+ );
182
+ expect(res.status).toBe(400);
183
+ });
184
+ });
185
+
186
+ describe("cap-overview list sort", () => {
187
+ test("sort:tier actually reorders the list, not just sortDirection", async () => {
188
+ const sysAdmin = createTestUser({ id: 90019, tenantId: TENANT_A, roles: ["SystemAdmin"] });
189
+ const result = await stack.http.queryOk<{
190
+ rows: readonly { tenantId: string; tier: string }[];
191
+ }>(CapOverviewQueries.tenantCapsList, { sort: "tier", sortDirection: "asc" }, sysAdmin);
192
+ const tierOrder = result.rows.map((row) => row.tier);
193
+ const sortedAscending = [...tierOrder].sort((a, b) => a.localeCompare(b));
194
+ expect(tierOrder).toEqual(sortedAscending);
195
+ expect(tierOrder).toContain("starter");
196
+ });
197
+
198
+ test("an unsupported sort field is rejected, not silently ignored", async () => {
199
+ const sysAdmin = createTestUser({ id: 90020, tenantId: TENANT_A, roles: ["SystemAdmin"] });
200
+ const res = await stack.http.query(
201
+ CapOverviewQueries.tenantCapsList,
202
+ { sort: "usage" },
203
+ sysAdmin,
204
+ );
205
+ expect(res.status).toBe(400);
206
+ });
207
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { billingFoundationFeature } from "../../billing-foundation";
4
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
5
+ import { createConfigFeature } from "../../config/feature";
6
+ import { createTenantFeature } from "../../tenant";
7
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
8
+ import { tierEngineFeature } from "../../tier-engine";
9
+ import {
10
+ MY_CAPS_SCREEN_ID,
11
+ PLATFORM_TENANT_CAPS_SCREEN_ID,
12
+ TENANT_CAP_LIST_SCREEN_ID,
13
+ } from "../constants";
14
+ import { createCapOverviewFeature } from "../feature";
15
+ import type { CapSpec } from "../types";
16
+
17
+ const testCap: CapSpec = {
18
+ id: "widgets",
19
+ label: "test.cap.widgets",
20
+ limit: () => 5,
21
+ usage: async () => 0,
22
+ };
23
+
24
+ describe("cap-overview boot", () => {
25
+ test("boots cleanly with a minimal cap set", () => {
26
+ const features = [
27
+ createConfigFeature(),
28
+ createTenantFeature(),
29
+ createComplianceProfilesFeature(),
30
+ createTenantLifecycleFeature(),
31
+ tierEngineFeature,
32
+ billingFoundationFeature,
33
+ createCapOverviewFeature({ caps: [testCap] }),
34
+ ];
35
+ expect(() => validateBoot(features)).not.toThrow();
36
+ });
37
+
38
+ test("boots cleanly with the tier facet enabled (facets + filters schema wiring)", () => {
39
+ const features = [
40
+ createConfigFeature(),
41
+ createTenantFeature(),
42
+ createComplianceProfilesFeature(),
43
+ createTenantLifecycleFeature(),
44
+ tierEngineFeature,
45
+ billingFoundationFeature,
46
+ createCapOverviewFeature({ caps: [testCap], tiers: ["free", "pro"] }),
47
+ ];
48
+ expect(() => validateBoot(features)).not.toThrow();
49
+ });
50
+
51
+ test("registers all three queries and three screens", () => {
52
+ const feature = createCapOverviewFeature({ caps: [testCap] });
53
+ expect(feature.queryHandlers["tenant-caps:list"]).toBeDefined();
54
+ expect(feature.queryHandlers["caps:usage"]).toBeDefined();
55
+ expect(feature.queryHandlers["tenant-options"]).toBeDefined();
56
+ expect(feature.screens[TENANT_CAP_LIST_SCREEN_ID]?.type).toBe("projectionList");
57
+ expect(feature.screens[MY_CAPS_SCREEN_ID]?.type).toBe("dashboard");
58
+ expect(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.type).toBe("dashboard");
59
+ });
60
+
61
+ test("tenant-cap-list facet field is a declared column and the list handler schema accepts filters", () => {
62
+ const feature = createCapOverviewFeature({ caps: [testCap], tiers: ["free", "pro"] });
63
+ const screen = feature.screens[TENANT_CAP_LIST_SCREEN_ID];
64
+ if (screen?.type !== "projectionList") throw new Error("expected projectionList screen");
65
+ const facetField = screen.facets?.[0]?.field;
66
+ const columnFields = screen.columns.map((column) =>
67
+ typeof column === "string" ? column : column.field,
68
+ );
69
+ expect(facetField).toBe("tier");
70
+ expect(columnFields).toContain("tier");
71
+ expect(columnFields).toContain(`cap_${testCap.id}`);
72
+ });
73
+
74
+ test("no tier facet when `tiers` is omitted", () => {
75
+ const feature = createCapOverviewFeature({ caps: [testCap] });
76
+ const screen = feature.screens[TENANT_CAP_LIST_SCREEN_ID];
77
+ if (screen?.type !== "projectionList") throw new Error("expected projectionList screen");
78
+ expect(screen.facets ?? []).toHaveLength(0);
79
+ });
80
+
81
+ // Deep-link: clicking a row on tenant-cap-list must land on THAT tenant's
82
+ // dashboard, not an empty one — the boot validator now lets a navigate
83
+ // rowAction's params through onto a dashboard target when its extractor
84
+ // produces the target's own `filter.id` (framework#1708 follow-up).
85
+ test("row-click deep-links into platform-tenant-caps with a params extractor matching its filter id", () => {
86
+ const features = [
87
+ createConfigFeature(),
88
+ createTenantFeature(),
89
+ createComplianceProfilesFeature(),
90
+ createTenantLifecycleFeature(),
91
+ tierEngineFeature,
92
+ billingFoundationFeature,
93
+ createCapOverviewFeature({ caps: [testCap] }),
94
+ ];
95
+ expect(() => validateBoot(features)).not.toThrow();
96
+
97
+ const feature = createCapOverviewFeature({ caps: [testCap] });
98
+ const listScreen = feature.screens[TENANT_CAP_LIST_SCREEN_ID];
99
+ if (listScreen?.type !== "projectionList") throw new Error("expected projectionList screen");
100
+ const rowAction = listScreen.rowActions?.[0];
101
+ if (rowAction?.kind !== "navigate") throw new Error("expected a navigate rowAction");
102
+ expect(rowAction.rowClick).toBe(true);
103
+ expect(rowAction.screen).toBe(PLATFORM_TENANT_CAPS_SCREEN_ID);
104
+ expect(rowAction.params).toEqual({ map: { tenantId: "tenantId" } });
105
+
106
+ const dashboardScreen = feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID];
107
+ if (dashboardScreen?.type !== "dashboard") throw new Error("expected dashboard screen");
108
+ expect(dashboardScreen.filter?.id).toBe("tenantId");
109
+ });
110
+
111
+ test("throws at construction when listCaps references an unknown cap id", () => {
112
+ expect(() =>
113
+ createCapOverviewFeature({ caps: [testCap], listCaps: ["does-not-exist"] }),
114
+ ).toThrow(/unknown cap id "does-not-exist"/);
115
+ });
116
+
117
+ test("throws when caps is empty", () => {
118
+ expect(() => createCapOverviewFeature({ caps: [] })).toThrow(/must not be empty/);
119
+ });
120
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math";
3
+
4
+ describe("computeFraction", () => {
5
+ test("used 3 of limit 5 -> 0.6", () => {
6
+ expect(computeFraction(3, 5)).toBe(0.6);
7
+ });
8
+
9
+ test("limit 0 -> 0, never NaN/Infinity", () => {
10
+ expect(computeFraction(5, 0)).toBe(0);
11
+ expect(computeFraction(0, 0)).toBe(0);
12
+ });
13
+
14
+ test("negative limit -> 0", () => {
15
+ expect(computeFraction(1, -5)).toBe(0);
16
+ });
17
+
18
+ test("used over limit clamps to 1", () => {
19
+ expect(computeFraction(9, 5)).toBe(1);
20
+ });
21
+
22
+ test("used 0 of a positive limit -> 0", () => {
23
+ expect(computeFraction(0, 5)).toBe(0);
24
+ });
25
+ });
26
+
27
+ describe("computeUnclampedFraction", () => {
28
+ test("used over limit -> ratio above 1, unlike computeFraction", () => {
29
+ expect(computeUnclampedFraction(7, 5)).toBe(1.4);
30
+ expect(computeFraction(7, 5)).toBe(1);
31
+ });
32
+
33
+ test("limit 0 -> 0, never NaN/Infinity", () => {
34
+ expect(computeUnclampedFraction(5, 0)).toBe(0);
35
+ });
36
+
37
+ test("used 3 of limit 5 -> 0.6, same as the clamped case", () => {
38
+ expect(computeUnclampedFraction(3, 5)).toBe(0.6);
39
+ });
40
+ });
41
+
42
+ describe("computeTone", () => {
43
+ test("below 0.8 -> default", () => {
44
+ expect(computeTone(0)).toBe("default");
45
+ expect(computeTone(0.79)).toBe("default");
46
+ });
47
+
48
+ test("0.8 up to (not including) 1 -> warn", () => {
49
+ expect(computeTone(0.8)).toBe("warn");
50
+ expect(computeTone(0.99)).toBe("warn");
51
+ });
52
+
53
+ test("5/5 (fraction 1) -> danger", () => {
54
+ expect(computeTone(computeFraction(5, 5))).toBe("danger");
55
+ });
56
+
57
+ test("fraction >= 1 -> danger", () => {
58
+ expect(computeTone(1)).toBe("danger");
59
+ });
60
+ });
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.224.0",
4
+ "type": "improvement",
5
+ "title": "Add cap-overview feature: tier + cap usage visibility per tenant.",
6
+ "detail": "New read-only bundled feature. SystemAdmin gets a paged, searchable tenant list with tier/billing columns and per-cap usage bars, plus a per-tenant dashboard reachable from the list. TenantAdmin gets a 'My usage' dashboard showing their own tenant's cap usage as cards. Reads tier-engine, billing-foundation and tenant projections plus caller-supplied CapSpec usage callbacks; every handler enforces explicit tenant scoping (no ambient tenant filter, cross-tenant reads require SystemAdmin + acknowledgeCrossTenant)."
7
+ }
8
+ ]
@@ -0,0 +1,26 @@
1
+ // @runtime client
2
+ // Pure string-literal constants — imported by both server (feature.ts,
3
+ // handlers/) and client (web/) code. See tier-engine/constants.ts for the
4
+ // same convention.
5
+
6
+ export const CAP_OVERVIEW_FEATURE = "cap-overview" as const;
7
+
8
+ export const TENANT_CAP_LIST_SCREEN_ID = "tenant-cap-list" as const;
9
+ export const MY_CAPS_SCREEN_ID = "my-caps" as const;
10
+ export const PLATFORM_TENANT_CAPS_SCREEN_ID = "platform-tenant-caps" as const;
11
+
12
+ export const CapOverviewQueries = {
13
+ tenantCapsList: "cap-overview:query:tenant-caps:list",
14
+ capsUsage: "cap-overview:query:caps:usage",
15
+ tenantOptions: "cap-overview:query:tenant-options",
16
+ } as const;
17
+
18
+ export const CAP_USAGE_CELL_COMPONENT = "CapUsageCell" as const;
19
+ export const CAP_CARDS_PANEL_COMPONENT = "CapCardsPanel" as const;
20
+
21
+ // Virtual column field name for a cap's usage cell on tenant-cap-list —
22
+ // prefixed so it can never collide with a real projection column ("name",
23
+ // "tier", "billing").
24
+ export function capFieldName(capId: string): string {
25
+ return `cap_${capId}`;
26
+ }