@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.
- package/package.json +9 -7
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/config/__tests__/config.integration.test.ts +1 -4
- package/src/config/__tests__/env-overrides.test.ts +21 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +1 -4
- package/src/config/handlers/cascade.query.ts +1 -3
- package/src/config/handlers/readiness.query.ts +6 -0
- package/src/config/handlers/values.query.ts +1 -3
- package/src/config/read-redaction.ts +13 -2
- package/src/config/resolver.ts +12 -5
- package/src/jobs/feature.ts +1 -3
- package/src/jobs/handlers/projection-rebuild.job.ts +1 -7
- package/src/page-render/__tests__/security-headers.test.ts +22 -0
- package/src/page-render/security-headers.ts +4 -1
- package/src/tags/__tests__/drift.test.ts +46 -0
- package/src/tags/__tests__/feature.test.ts +121 -0
- package/src/tags/__tests__/tags.integration.test.ts +185 -0
- package/src/tags/aggregate-id.ts +23 -0
- package/src/tags/constants.ts +28 -0
- package/src/tags/entity.ts +35 -0
- package/src/tags/executor.ts +11 -0
- package/src/tags/feature.ts +70 -0
- package/src/tags/handlers/assign-tag.write.ts +50 -0
- package/src/tags/handlers/create-tag.write.ts +25 -0
- package/src/tags/handlers/remove-tag.write.ts +36 -0
- package/src/tags/index.ts +29 -0
- package/src/tags/schemas.ts +20 -0
- package/src/template-resolver/README.md +22 -0
- package/src/template-resolver/__tests__/conformance.integration.test.ts +79 -0
- package/src/template-resolver/testing.ts +192 -0
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +21 -0
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +10 -1
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +48 -35
- 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.
|
|
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.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
86
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
87
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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": "^
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
18
|
-
// value
|
|
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
|
}
|
package/src/config/resolver.ts
CHANGED
|
@@ -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
|
|
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 —
|
|
650
|
-
|
|
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);
|
package/src/jobs/feature.ts
CHANGED
|
@@ -153,9 +153,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
153
153
|
},
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
// Framework-provided
|
|
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 (
|
|
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 { ...
|
|
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
|
+
});
|