@cosmicdrift/kumiko-bundled-features 0.57.1 → 0.59.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 (34) hide show
  1. package/package.json +9 -7
  2. package/src/auth-email-password/i18n.ts +2 -0
  3. package/src/config/__tests__/config.integration.test.ts +1 -4
  4. package/src/config/__tests__/env-overrides.test.ts +21 -0
  5. package/src/config/handlers/__tests__/prepare-config-write.test.ts +1 -4
  6. package/src/config/handlers/cascade.query.ts +1 -3
  7. package/src/config/handlers/readiness.query.ts +6 -0
  8. package/src/config/handlers/values.query.ts +1 -3
  9. package/src/config/read-redaction.ts +13 -2
  10. package/src/config/resolver.ts +12 -5
  11. package/src/jobs/feature.ts +1 -3
  12. package/src/jobs/handlers/projection-rebuild.job.ts +1 -7
  13. package/src/page-render/__tests__/security-headers.test.ts +22 -0
  14. package/src/page-render/security-headers.ts +4 -1
  15. package/src/tags/__tests__/drift.test.ts +46 -0
  16. package/src/tags/__tests__/feature.test.ts +121 -0
  17. package/src/tags/__tests__/tags.integration.test.ts +185 -0
  18. package/src/tags/aggregate-id.ts +23 -0
  19. package/src/tags/constants.ts +28 -0
  20. package/src/tags/entity.ts +35 -0
  21. package/src/tags/executor.ts +11 -0
  22. package/src/tags/feature.ts +70 -0
  23. package/src/tags/handlers/assign-tag.write.ts +50 -0
  24. package/src/tags/handlers/create-tag.write.ts +25 -0
  25. package/src/tags/handlers/remove-tag.write.ts +36 -0
  26. package/src/tags/index.ts +29 -0
  27. package/src/tags/schemas.ts +20 -0
  28. package/src/template-resolver/README.md +22 -0
  29. package/src/template-resolver/__tests__/conformance.integration.test.ts +79 -0
  30. package/src/template-resolver/testing.ts +192 -0
  31. package/src/user-data-rights/__tests__/request-deletion-url.test.ts +21 -0
  32. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +10 -1
  33. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +48 -35
  34. package/src/user-data-rights/web/confirm-deletion-screen.tsx +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.57.1",
3
+ "version": "0.59.1",
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>",
@@ -29,6 +29,7 @@
29
29
  "./cap-counter": "./src/cap-counter/index.ts",
30
30
  "./custom-fields": "./src/custom-fields/index.ts",
31
31
  "./custom-fields/web": "./src/custom-fields/web/index.ts",
32
+ "./tags": "./src/tags/index.ts",
32
33
  "./billing-foundation": "./src/billing-foundation/index.ts",
33
34
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
34
35
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
@@ -72,6 +73,7 @@
72
73
  "./text-content/seeding": "./src/text-content/seeding.ts",
73
74
  "./text-content/web": "./src/text-content/web/index.ts",
74
75
  "./template-resolver": "./src/template-resolver/index.ts",
76
+ "./template-resolver/testing": "./src/template-resolver/testing.ts",
75
77
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
76
78
  "./legal-pages": "./src/legal-pages/index.ts",
77
79
  "./legal-pages/web": "./src/legal-pages/web/index.ts",
@@ -80,18 +82,18 @@
80
82
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
81
83
  },
82
84
  "dependencies": {
83
- "@cosmicdrift/kumiko-dispatcher-live": "0.55.1",
84
- "@cosmicdrift/kumiko-framework": "0.55.1",
85
- "@cosmicdrift/kumiko-headless": "0.55.1",
86
- "@cosmicdrift/kumiko-renderer": "0.55.1",
87
- "@cosmicdrift/kumiko-renderer-web": "0.55.1",
85
+ "@cosmicdrift/kumiko-dispatcher-live": "0.57.2",
86
+ "@cosmicdrift/kumiko-framework": "0.57.2",
87
+ "@cosmicdrift/kumiko-headless": "0.57.2",
88
+ "@cosmicdrift/kumiko-renderer": "0.57.2",
89
+ "@cosmicdrift/kumiko-renderer-web": "0.57.2",
88
90
  "@mollie/api-client": "^4.5.0",
89
91
  "@node-rs/argon2": "^2.0.2",
90
92
  "@types/nodemailer": "^8.0.0",
91
93
  "clsx": "^2.1.1",
92
94
  "lucide-react": "^1.14.0",
93
95
  "marked": "^18.0.3",
94
- "nodemailer": "^8.0.7",
96
+ "nodemailer": "^9.0.1",
95
97
  "react": "^19.2.6",
96
98
  "stripe": "^22.1.1",
97
99
  "tailwind-merge": "^3.6.0"
@@ -40,6 +40,7 @@ export const defaultTranslations: TranslationsByLocale = {
40
40
  "auth.errors.signupEmailAlreadyRegistered":
41
41
  "Für diese E-Mail-Adresse existiert bereits ein Konto. Bitte logge dich ein oder setze dein Passwort zurück.",
42
42
  "auth.errors.unknownError": "Etwas ist schief gegangen. Bitte erneut versuchen.",
43
+ "auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
43
44
  "auth.forgotPassword.title": "Passwort zurücksetzen",
44
45
  "auth.forgotPassword.intro":
45
46
  "Gib deine E-Mail-Adresse ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.",
@@ -140,6 +141,7 @@ export const defaultTranslations: TranslationsByLocale = {
140
141
  "auth.errors.signupEmailAlreadyRegistered":
141
142
  "An account already exists for this email. Please sign in or reset your password.",
142
143
  "auth.errors.unknownError": "Something went wrong. Please try again.",
144
+ "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
143
145
  "auth.forgotPassword.title": "Reset password",
144
146
  "auth.forgotPassword.intro":
145
147
  "Enter your email. If an account exists, we'll send you a reset link.",
@@ -175,10 +175,7 @@ const integrationFeature = defineFeature("integration", (r) => {
175
175
  default: "initial",
176
176
  write: access.roles("Admin"),
177
177
  }),
178
- // Settings-Hub system-scope proxies for the derived Stripe screen: a
179
- // privileged boolean (billing-live = machine OR human-SystemAdmin) and
180
- // an encrypted system secret (api-key). Both are surfaced to a human
181
- // SystemAdmin by build-config-feature-schema and must be SET-able by them.
178
+ // Privileged + encrypted system keys must be SET-able by a human SystemAdmin (the derived Stripe screen surfaces both).
182
179
  billingLive: createSystemConfig("boolean", {
183
180
  default: false,
184
181
  write: access.privileged,
@@ -88,6 +88,27 @@ describe("buildEnvConfigOverrides", () => {
88
88
  expect(result.size).toBe(0);
89
89
  });
90
90
 
91
+ test("whitespace-only env var → skipped (semantically empty, must not clobber default)", () => {
92
+ // Number key: pre-fix `Number(" ".trim())` was 0 and finite → silently
93
+ // resolved to 0 instead of falling through to the declared default.
94
+ const reg = registryStub({
95
+ "a:config:n": createSystemConfig("number", { env: "N" }),
96
+ "a:config:x": createSystemConfig("text", { env: "X" }),
97
+ });
98
+ const result = buildEnvConfigOverrides(reg, { N: " ", X: "\t\n" });
99
+ expect(result.size).toBe(0);
100
+ });
101
+
102
+ test("select value is trimmed before option membership (so ` dark` resolves)", () => {
103
+ const reg = registryStub({
104
+ "a:config:theme": createSystemConfig("select", {
105
+ env: "THEME",
106
+ options: ["light", "dark"],
107
+ }),
108
+ });
109
+ expect(buildEnvConfigOverrides(reg, { THEME: " dark " }).get("a:config:theme")).toBe("dark");
110
+ });
111
+
91
112
  test("keys without an env field are ignored even if a same-named var exists", () => {
92
113
  const reg = registryStub({
93
114
  "a:config:no-env": createSystemConfig("text", {}),
@@ -106,10 +106,7 @@ describe("prepareConfigWrite", () => {
106
106
  });
107
107
 
108
108
  test("privileged key (system + SystemAdmin) is writable by a human SystemAdmin", () => {
109
- // The derived configEdit screen surfaces a `access.privileged`
110
- // (`["system","SystemAdmin"]`) key to a human SystemAdmin (e.g. Stripe
111
- // billing-live). The write must succeed — "system in the write-set"
112
- // means machine-OR-operator, not machine-only.
109
+ // "system" in the write-set means machine-OR-operator, not machine-only — a human SystemAdmin must still be able to write.
113
110
  const privilegedKey = createSystemConfig("boolean", { write: access.privileged });
114
111
  const result = prepareConfigWrite({
115
112
  registry: registryStub({ "ns:config:billing-live": privilegedKey }),
@@ -5,11 +5,9 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { z } from "zod";
7
7
  import { requireConfigResolver } from "../feature";
8
- import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
8
+ import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
9
9
  import { hasConfigAccess } from "../write-helpers";
10
10
 
11
- const MASKED = "••••••";
12
-
13
11
  export const cascadeQuery = defineQueryHandler({
14
12
  name: "cascade",
15
13
  schema: z.object({
@@ -101,6 +101,12 @@ export async function collectMissingRequiredConfig(
101
101
  ctx.secrets,
102
102
  );
103
103
  for (const [qualifiedKey, keyDef] of candidates) {
104
+ // Deliberately the unredacted cascade value: readiness asks "does this
105
+ // tenant functionally have the value?", and an inheritedToTenant:false key
106
+ // set only at system-level IS inherited (the resolver ignores
107
+ // inheritedToTenant — that flag only redacts the value queries' display).
108
+ // Redacting here would report a working key as missing. The is-set bit this
109
+ // exposes is intentional; see read-redaction.ts.
104
110
  const value = cascades.get(qualifiedKey)?.value;
105
111
  if (isUnset(value, keyDef.type)) {
106
112
  missing.push({ key: qualifiedKey, scope: keyDef.scope, type: keyDef.type });
@@ -6,11 +6,9 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
8
  import { requireConfigResolver } from "../feature";
9
- import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
9
+ import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
10
10
  import { hasConfigAccess } from "../write-helpers";
11
11
 
12
- const MASKED = "••••••";
13
-
14
12
  export const valuesQuery = defineQueryHandler({
15
13
  name: "values",
16
14
  schema: z.object({}),
@@ -14,8 +14,19 @@ const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant
14
14
 
15
15
  // A SystemAdmin owns the platform-level values and may always see them. Every
16
16
  // other viewer (TenantAdmin, User) is tenant-side — for an
17
- // inheritedToTenant:false key they must learn neither the inherited platform
18
- // value nor that it is set.
17
+ // inheritedToTenant:false key the value-returning read handlers (cascade +
18
+ // values) hide both the inherited platform value and that it is set.
19
+ //
20
+ // Scope note: redaction is display-only. The resolver does NOT consult
21
+ // inheritedToTenant (zero reads in resolver.ts), so the tenant still
22
+ // functionally inherits and uses the value, and config:query:readiness
23
+ // deliberately reports such a key as satisfied (is-set) rather than missing —
24
+ // flagging a working key as missing would nag tenants to set already-
25
+ // functioning config. So "nor that it is set" holds for the value queries, not
26
+ // for the functional readiness rollup. See readiness.query.ts.
27
+ // Shared mask for redacted config values across the read handlers (cascade + values).
28
+ export const MASKED = "••••••";
29
+
19
30
  export function mayViewInheritedValue(roles: readonly string[]): boolean {
20
31
  return roles.includes(SYSTEM_ADMIN_ROLE);
21
32
  }
@@ -384,6 +384,12 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
384
384
  return { value: undefined, source: "missing" };
385
385
  },
386
386
 
387
+ // NOTE: getAll / getAllWithSource read only config_values rows and do NOT
388
+ // dispatch to the secrets store (unlike get/getCascade/getWithSource). A
389
+ // backing="secrets" key has no config_values row, so it is simply ABSENT
390
+ // from the result map — never a stale/undefined value. Callers needing a
391
+ // secrets-backed value must use get/getCascade; these bulk readers are for
392
+ // the config_values-backed keys only.
387
393
  async getAll(tenantId, userId, db) {
388
394
  // Only load rows relevant to this user/tenant (system + tenant + user scope)
389
395
  const rows = await selectConfigRowsForScope(db, SYSTEM_TENANT_ID, tenantId, userId);
@@ -613,8 +619,8 @@ export function validateAppOverrides(
613
619
  // resolving to the wrong value. Reuses validateAppOverrides for the
614
620
  // existence / bounds / options / computed-conflict gates.
615
621
  //
616
- // undefined OR empty-string env vars are skipped — an unset (or `FOO=`)
617
- // variable must not clobber a declared default.
622
+ // undefined OR blank env vars are skipped — an unset, empty (`FOO=`) or
623
+ // whitespace-only (`FOO=" "`) variable must not clobber a declared default.
618
624
 
619
625
  type EnvSource = Readonly<Record<string, string | undefined>>;
620
626
 
@@ -646,8 +652,9 @@ function coerceEnvValue(
646
652
  `ENV config bridge: "${envName}" → "${qualifiedKey}" expects a boolean (true/false/1/0), got "${raw}".`,
647
653
  );
648
654
  }
649
- // text | select — select-option membership is validated by validateAppOverrides
650
- return raw;
655
+ // text | select — trimmed so e.g. `THEME=" dark"` resolves to a real option;
656
+ // select-option membership is validated by validateAppOverrides.
657
+ return raw.trim();
651
658
  }
652
659
 
653
660
  export function buildEnvConfigOverrides(
@@ -659,7 +666,7 @@ export function buildEnvConfigOverrides(
659
666
  const envName = keyDef.env;
660
667
  if (!envName) continue;
661
668
  const raw = env[envName];
662
- if (raw === undefined || raw === "") continue;
669
+ if (raw === undefined || raw.trim() === "") continue;
663
670
  record[qualifiedKey] = coerceEnvValue(qualifiedKey, envName, keyDef.type, raw);
664
671
  }
665
672
  return validateAppOverrides(registry, record);
@@ -153,9 +153,7 @@ export function createJobsFeature(): FeatureDefinition {
153
153
  },
154
154
  });
155
155
 
156
- // Framework-provided single-run rebuild job (QN `jobs:job:projection-rebuild`).
157
- // Available whenever `jobs` is composed — `enqueueProjectionRebuild` dispatches
158
- // it; every run is tracked in read_job_runs + retryable via jobs:write:retry.
156
+ // Framework-provided rebuild job available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
159
157
  r.job(
160
158
  "projectionRebuild",
161
159
  { trigger: { manual: true }, schema: projectionRebuildPayloadSchema },
@@ -1,10 +1,4 @@
1
- // Single-run projection-rebuild worker (QN `jobs:job:projection-rebuild`).
2
- // Replays the event log into one projection via the framework's
3
- // `rebuildProjection`. Triggered manually — typically through
4
- // `enqueueProjectionRebuild` (migrations) as the self-service repair for an
5
- // emptied projection, a deliberate manual rebuild, or a post-upcaster refill.
6
- // Run-tracking (read_job_runs + read_job_run_logs) and retry come for free
7
- // from the jobs feature that registers this worker.
1
+ // Single-run projection-rebuild worker (`jobs:job:projection-rebuild`); manually triggered, typically via enqueueProjectionRebuild to refill an emptied projection.
8
2
 
9
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
4
  import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { securePageHeaders } from "../security-headers";
3
+
4
+ describe("securePageHeaders", () => {
5
+ test("merges caller headers alongside the security defaults", () => {
6
+ const h = securePageHeaders({ "content-type": "text/html; charset=utf-8" });
7
+ expect(h["content-type"]).toBe("text/html; charset=utf-8");
8
+ expect(h["x-content-type-options"]).toBe("nosniff");
9
+ expect(h["content-security-policy"]).toContain("script-src 'none'");
10
+ });
11
+
12
+ test("a caller can NEVER override a hardened security header", () => {
13
+ const h = securePageHeaders({
14
+ "content-security-policy": "default-src *",
15
+ "x-frame-options": "ALLOWALL",
16
+ });
17
+ expect(h["content-security-policy"]).toBe(
18
+ "script-src 'none'; object-src 'none'; base-uri 'none'",
19
+ );
20
+ expect(h["x-frame-options"]).toBe("SAMEORIGIN");
21
+ });
22
+ });
@@ -11,6 +11,9 @@ const PUBLIC_PAGE_SECURITY_HEADERS = {
11
11
  "referrer-policy": "strict-origin-when-cross-origin",
12
12
  } as const;
13
13
 
14
+ // Security headers spread LAST so a caller's `extra` can never override a
15
+ // hardened default (CSP/nosniff/frame-options); extra only adds non-security
16
+ // headers like content-type/cache-control/vary.
14
17
  export function securePageHeaders(extra: Record<string, string>): Record<string, string> {
15
- return { ...PUBLIC_PAGE_SECURITY_HEADERS, ...extra };
18
+ return { ...extra, ...PUBLIC_PAGE_SECURITY_HEADERS };
16
19
  }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { tagAssignmentAggregateId } from "../aggregate-id";
3
+
4
+ // Drift-Pin-Tests — these values are cross-boot contracts. If they go red:
5
+ // stop, think, revert. aggregate-id.ts names this file.
6
+
7
+ const TENANT = "00000000-0000-0000-0000-000000000001";
8
+
9
+ describe("tags drift pins", () => {
10
+ test("tag-assignment aggregate-id namespace is stable across boots", () => {
11
+ // TAG_ASSIGNMENT_NAMESPACE is in stone — changing it re-keys every existing
12
+ // assignment stream and breaks event-replay. If this fails: revert the
13
+ // namespace, do not adjust the expected values.
14
+ const base = tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-1");
15
+
16
+ expect(base).toBe(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-1")); // deterministic
17
+ // Every tuple component is part of the key (no collisions across the axes).
18
+ expect(base).not.toBe(
19
+ tagAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "tag-1", "credit", "c-1"),
20
+ );
21
+ expect(base).not.toBe(tagAssignmentAggregateId(TENANT, "tag-2", "credit", "c-1"));
22
+ expect(base).not.toBe(tagAssignmentAggregateId(TENANT, "tag-1", "invoice", "c-1"));
23
+ expect(base).not.toBe(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-2"));
24
+
25
+ // Pinned actual outputs — the drift-detector for the namespace constant.
26
+ expect(base).toBe("4f6e3d2e-033b-57f8-b044-6a3358647f65");
27
+ expect(
28
+ tagAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "tag-1", "credit", "c-1"),
29
+ ).toBe("1bc17669-25ad-565b-9caf-72dbb18756da");
30
+ expect(tagAssignmentAggregateId(TENANT, "tag-2", "credit", "c-1")).toBe(
31
+ "6de1c5c6-25a1-508e-b1f8-de914745406d",
32
+ );
33
+ expect(tagAssignmentAggregateId(TENANT, "tag-1", "invoice", "c-1")).toBe(
34
+ "4e0d68b6-a69b-5dc1-9a2a-cd3f7e8b179d",
35
+ );
36
+ expect(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-2")).toBe(
37
+ "659a1f64-31c0-5365-82e6-512fd822f002",
38
+ );
39
+ });
40
+
41
+ test("aggregate-id format is a valid uuid", () => {
42
+ expect(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-1")).toMatch(
43
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DEFAULT_TAG_ROLES } from "../constants";
3
+ import { createTagsFeature } from "../feature";
4
+ import { assignTagPayloadSchema, createTagPayloadSchema, removeTagPayloadSchema } from "../schemas";
5
+
6
+ // Unit tests: feature-shape, role-options, schema-validation. The ES-loop
7
+ // behaviour (idempotent assign/remove, projection, tenant-isolation, read
8
+ // composition) needs a real stack → tags.integration.test.ts.
9
+
10
+ function writeAccess(
11
+ feature: ReturnType<typeof createTagsFeature>,
12
+ nameMatch: string,
13
+ ): readonly string[] {
14
+ const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
15
+ if (!entry) throw new Error(`handler ${nameMatch} not registered`);
16
+ const access = entry[1].access;
17
+ if (!access || !("roles" in access)) throw new Error(`handler ${nameMatch} has no roles`);
18
+ return access.roles;
19
+ }
20
+
21
+ function queryAccess(
22
+ feature: ReturnType<typeof createTagsFeature>,
23
+ nameMatch: string,
24
+ ): readonly string[] {
25
+ const entry = Object.entries(feature.queryHandlers).find(([qn]) => qn.includes(nameMatch));
26
+ if (!entry) throw new Error(`query ${nameMatch} not registered`);
27
+ const access = entry[1].access;
28
+ if (!access || !("roles" in access)) throw new Error(`query ${nameMatch} has no roles`);
29
+ return access.roles;
30
+ }
31
+
32
+ describe("createTagsFeature shape", () => {
33
+ test("registers tag + tag-assignment entities, 3 write-handlers, 2 query-handlers", () => {
34
+ const feature = createTagsFeature();
35
+
36
+ expect(Object.keys(feature.entities ?? {})).toEqual(
37
+ expect.arrayContaining(["tag", "tag-assignment"]),
38
+ );
39
+
40
+ expect(Object.keys(feature.writeHandlers)).toEqual(
41
+ expect.arrayContaining([
42
+ expect.stringMatching(/create-tag/),
43
+ expect.stringMatching(/assign-tag/),
44
+ expect.stringMatching(/remove-tag/),
45
+ ]),
46
+ );
47
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(3);
48
+
49
+ expect(Object.keys(feature.queryHandlers)).toEqual(
50
+ expect.arrayContaining([
51
+ expect.stringMatching(/tag:list/),
52
+ expect.stringMatching(/tag-assignment:list/),
53
+ ]),
54
+ );
55
+ expect(Object.keys(feature.queryHandlers)).toHaveLength(2);
56
+ });
57
+ });
58
+
59
+ describe("createTagsFeature access-options", () => {
60
+ test("without options: singleton with default roles on every path", () => {
61
+ const feature = createTagsFeature();
62
+ expect(feature).toBe(createTagsFeature());
63
+ expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
64
+ expect(writeAccess(feature, "assign-tag")).toEqual([...DEFAULT_TAG_ROLES]);
65
+ expect(writeAccess(feature, "remove-tag")).toEqual([...DEFAULT_TAG_ROLES]);
66
+ expect(queryAccess(feature, "tag:list")).toEqual([...DEFAULT_TAG_ROLES]);
67
+ expect(queryAccess(feature, "tag-assignment:list")).toEqual([...DEFAULT_TAG_ROLES]);
68
+ });
69
+
70
+ test("roles option overrides every write- and query-path", () => {
71
+ const feature = createTagsFeature({ roles: ["Admin", "Editor"] });
72
+ expect(writeAccess(feature, "create-tag")).toEqual(["Admin", "Editor"]);
73
+ expect(writeAccess(feature, "assign-tag")).toEqual(["Admin", "Editor"]);
74
+ expect(writeAccess(feature, "remove-tag")).toEqual(["Admin", "Editor"]);
75
+ expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
76
+ expect(queryAccess(feature, "tag-assignment:list")).toEqual(["Admin", "Editor"]);
77
+ });
78
+ });
79
+
80
+ describe("createTagPayloadSchema", () => {
81
+ test("accepts name only", () => {
82
+ expect(createTagPayloadSchema.safeParse({ name: "Kunde Müller" }).success).toBe(true);
83
+ });
84
+
85
+ test("accepts name + color", () => {
86
+ expect(createTagPayloadSchema.safeParse({ name: "VIP", color: "#d4af37" }).success).toBe(true);
87
+ });
88
+
89
+ test("rejects empty name", () => {
90
+ expect(createTagPayloadSchema.safeParse({ name: "" }).success).toBe(false);
91
+ });
92
+
93
+ test("rejects name over 64 chars", () => {
94
+ expect(createTagPayloadSchema.safeParse({ name: "x".repeat(65) }).success).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("assign/remove payload schemas", () => {
99
+ const valid = { tagId: "tag-1", entityType: "credit", entityId: "c-1" };
100
+
101
+ test("accept a full (tag, entity) reference", () => {
102
+ expect(assignTagPayloadSchema.safeParse(valid).success).toBe(true);
103
+ expect(removeTagPayloadSchema.safeParse(valid).success).toBe(true);
104
+ });
105
+
106
+ test("reject missing entityId", () => {
107
+ expect(assignTagPayloadSchema.safeParse({ tagId: "tag-1", entityType: "credit" }).success).toBe(
108
+ false,
109
+ );
110
+ });
111
+
112
+ test("reject empty tagId", () => {
113
+ expect(assignTagPayloadSchema.safeParse({ ...valid, tagId: "" }).success).toBe(false);
114
+ });
115
+
116
+ test("reject entityId over 128 chars", () => {
117
+ expect(assignTagPayloadSchema.safeParse({ ...valid, entityId: "x".repeat(129) }).success).toBe(
118
+ false,
119
+ );
120
+ });
121
+ });
@@ -0,0 +1,185 @@
1
+ // Full-stack integration for the tags bundle. Drives create → assign → list →
2
+ // remove through the real dispatcher + entity-projection + DB. Proves the
3
+ // architecture end-to-end WITHOUT any host wiring (tags are host-agnostic — the
4
+ // host is just the entityType/entityId strings on the assignment):
5
+ // - create-tag projects into read_tags
6
+ // - assign-tag projects a join row keyed by (entityType, entityId)
7
+ // - read-layer composition both directions (tags of an entity / entities of a tag)
8
+ // - assign + remove are idempotent (re-assign = one row, remove-missing = ok)
9
+ // - multi-tenant isolation
10
+
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import {
15
+ createTestUser,
16
+ setupTestStack,
17
+ type TestStack,
18
+ unsafeCreateEntityTable,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { TagsHandlers, TagsQueries } from "../constants";
21
+ import { tagAssignmentEntity, tagEntity } from "../entity";
22
+ import { createTagsFeature } from "../feature";
23
+
24
+ const tagsFeature = createTagsFeature();
25
+
26
+ let stack: TestStack;
27
+
28
+ beforeAll(async () => {
29
+ stack = await setupTestStack({ features: [tagsFeature] });
30
+ await unsafeCreateEntityTable(stack.db, tagEntity);
31
+ await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
32
+ await createEventsTable(stack.db);
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await stack.cleanup();
37
+ });
38
+
39
+ beforeEach(async () => {
40
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
41
+ await asRawClient(stack.db).unsafe("DELETE FROM read_tags");
42
+ await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
43
+ });
44
+
45
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
46
+ const otherTenant = createTestUser({
47
+ roles: ["TenantAdmin"],
48
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
49
+ });
50
+
51
+ async function createTag(name: string, user = admin): Promise<string> {
52
+ const tag = await stack.http.writeOk<{ id: string }>(TagsHandlers.createTag, { name }, user);
53
+ return tag.id;
54
+ }
55
+
56
+ async function assign(tagId: string, entityType: string, entityId: string, user = admin) {
57
+ return stack.http.writeOk(TagsHandlers.assignTag, { tagId, entityType, entityId }, user);
58
+ }
59
+
60
+ async function remove(tagId: string, entityType: string, entityId: string, user = admin) {
61
+ return stack.http.writeOk(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
62
+ }
63
+
64
+ async function listTags(user = admin): Promise<Array<Record<string, unknown>>> {
65
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
66
+ TagsQueries.tagList,
67
+ {},
68
+ user,
69
+ );
70
+ return res.rows;
71
+ }
72
+
73
+ async function listAssignments(
74
+ filter: { field: string; op: "eq"; value: unknown } | undefined,
75
+ user = admin,
76
+ ): Promise<Array<Record<string, unknown>>> {
77
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
78
+ TagsQueries.assignmentList,
79
+ filter ? { filter } : {},
80
+ user,
81
+ );
82
+ return res.rows;
83
+ }
84
+
85
+ async function countAssignments(tenantId: string): Promise<number> {
86
+ const rows = await asRawClient(stack.db).unsafe(
87
+ "SELECT count(*)::int AS n FROM read_tag_assignments WHERE tenant_id = $1",
88
+ [tenantId],
89
+ );
90
+ return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
91
+ }
92
+
93
+ describe("tags integration — catalog + assignment roundtrip", () => {
94
+ test("create-tag lands in read_tags", async () => {
95
+ const id = await createTag("Kunde Müller");
96
+ const tags = await listTags();
97
+ expect(tags).toHaveLength(1);
98
+ expect(tags[0]?.["id"]).toBe(id);
99
+ expect(tags[0]?.["name"]).toBe("Kunde Müller");
100
+ });
101
+
102
+ test("assign-tag → assignment queryable both composition directions", async () => {
103
+ const tagId = await createTag("VIP");
104
+ await assign(tagId, "credit", "credit-1");
105
+
106
+ // tags of an entity
107
+ const byEntity = await listAssignments({ field: "entityId", op: "eq", value: "credit-1" });
108
+ expect(byEntity).toHaveLength(1);
109
+ expect(byEntity[0]?.["tagId"]).toBe(tagId);
110
+ expect(byEntity[0]?.["entityType"]).toBe("credit");
111
+
112
+ // entities carrying a tag
113
+ const byTag = await listAssignments({ field: "tagId", op: "eq", value: tagId });
114
+ expect(byTag).toHaveLength(1);
115
+ expect(byTag[0]?.["entityId"]).toBe("credit-1");
116
+ });
117
+
118
+ test("remove-tag deletes the assignment", async () => {
119
+ const tagId = await createTag("temp");
120
+ await assign(tagId, "credit", "credit-2");
121
+ expect(await countAssignments(admin.tenantId)).toBe(1);
122
+
123
+ await remove(tagId, "credit", "credit-2");
124
+ expect(await countAssignments(admin.tenantId)).toBe(0);
125
+ const left = await listAssignments({ field: "entityId", op: "eq", value: "credit-2" });
126
+ expect(left).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ describe("tags integration — many-to-many composition", () => {
131
+ test("one entity carries multiple tags", async () => {
132
+ const a = await createTag("rot");
133
+ const b = await createTag("wasser");
134
+ await assign(a, "credit", "credit-3");
135
+ await assign(b, "credit", "credit-3");
136
+
137
+ const tags = await listAssignments({ field: "entityId", op: "eq", value: "credit-3" });
138
+ expect(tags.map((r) => r["tagId"]).sort()).toEqual([a, b].sort());
139
+ });
140
+
141
+ test("one tag spans multiple entities", async () => {
142
+ const tagId = await createTag("Mappe-2026");
143
+ await assign(tagId, "credit", "credit-4");
144
+ await assign(tagId, "credit", "credit-5");
145
+
146
+ const entities = await listAssignments({ field: "tagId", op: "eq", value: tagId });
147
+ expect(entities.map((r) => r["entityId"]).sort()).toEqual(["credit-4", "credit-5"]);
148
+ });
149
+ });
150
+
151
+ describe("tags integration — idempotency", () => {
152
+ test("re-assigning the same (tag, entity) keeps exactly one row", async () => {
153
+ const tagId = await createTag("dup");
154
+ await assign(tagId, "credit", "credit-6");
155
+ await assign(tagId, "credit", "credit-6"); // re-assign: must be a no-op success
156
+
157
+ expect(await countAssignments(admin.tenantId)).toBe(1);
158
+ const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-6" });
159
+ expect(rows).toHaveLength(1);
160
+ });
161
+
162
+ test("removing a never-assigned (tag, entity) succeeds (no error, no row)", async () => {
163
+ const tagId = await createTag("ghost");
164
+ // never assigned — remove must still succeed (idempotent end-state)
165
+ await remove(tagId, "credit", "credit-7");
166
+ expect(await countAssignments(admin.tenantId)).toBe(0);
167
+ });
168
+ });
169
+
170
+ describe("tags integration — multi-tenant isolation", () => {
171
+ test("tenant B sees neither tenant A's tags nor assignments", async () => {
172
+ const tagId = await createTag("A-only", admin);
173
+ await assign(tagId, "credit", "credit-8", admin);
174
+
175
+ expect(await listTags(otherTenant)).toHaveLength(0);
176
+ expect(
177
+ await listAssignments({ field: "entityId", op: "eq", value: "credit-8" }, otherTenant),
178
+ ).toHaveLength(0);
179
+
180
+ // tenant A still sees its own
181
+ expect(await listTags(admin)).toHaveLength(1);
182
+ expect(await countAssignments(admin.tenantId)).toBe(1);
183
+ expect(await countAssignments(otherTenant.tenantId)).toBe(0);
184
+ });
185
+ });