@cosmicdrift/kumiko-bundled-features 0.57.2 → 0.60.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.
- package/package.json +10 -7
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
- package/src/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/custom-fields/__tests__/feature.test.ts +57 -4
- package/src/custom-fields/feature.ts +19 -4
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
- package/src/files-provider-s3/s3-provider.ts +9 -3
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
- package/src/managed-pages/handlers/set.write.ts +14 -4
- package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
- package/src/subscription-stripe/feature.ts +2 -1
- package/src/tags/__tests__/drift.test.ts +46 -0
- package/src/tags/__tests__/feature.test.ts +155 -0
- package/src/tags/__tests__/tags.integration.test.ts +251 -0
- package/src/tags/aggregate-id.ts +23 -0
- package/src/tags/constants.ts +37 -0
- package/src/tags/entity.ts +35 -0
- package/src/tags/executor.ts +11 -0
- package/src/tags/feature.ts +75 -0
- package/src/tags/handlers/assign-tag.write.ts +48 -0
- package/src/tags/handlers/create-tag.write.ts +23 -0
- package/src/tags/handlers/remove-tag.write.ts +34 -0
- package/src/tags/index.ts +30 -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/tier-engine/__tests__/drift.test.ts +4 -0
- package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
- package/src/tier-engine/constants.ts +13 -0
- package/src/tier-engine/entity.ts +5 -0
- package/src/tier-engine/feature.ts +51 -3
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
- package/src/tier-engine/i18n.ts +39 -0
- package/src/tier-engine/web/client-plugin.tsx +27 -0
- package/src/tier-engine/web/index.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/deletion-token.ts +9 -3
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +37 -43
- package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
- package/src/user-profile/i18n.ts +2 -3
- package/src/user-profile/web/profile-screen.tsx +29 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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>",
|
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
"./readiness": "./src/readiness/index.ts",
|
|
27
27
|
"./jobs": "./src/jobs/index.ts",
|
|
28
28
|
"./tier-engine": "./src/tier-engine/index.ts",
|
|
29
|
+
"./tier-engine/web": "./src/tier-engine/web/index.ts",
|
|
29
30
|
"./cap-counter": "./src/cap-counter/index.ts",
|
|
30
31
|
"./custom-fields": "./src/custom-fields/index.ts",
|
|
31
32
|
"./custom-fields/web": "./src/custom-fields/web/index.ts",
|
|
33
|
+
"./tags": "./src/tags/index.ts",
|
|
32
34
|
"./billing-foundation": "./src/billing-foundation/index.ts",
|
|
33
35
|
"./subscription-stripe": "./src/subscription-stripe/index.ts",
|
|
34
36
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
73
75
|
"./text-content/web": "./src/text-content/web/index.ts",
|
|
74
76
|
"./template-resolver": "./src/template-resolver/index.ts",
|
|
77
|
+
"./template-resolver/testing": "./src/template-resolver/testing.ts",
|
|
75
78
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
76
79
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
77
80
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
@@ -80,18 +83,18 @@
|
|
|
80
83
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
81
84
|
},
|
|
82
85
|
"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.
|
|
86
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.57.2",
|
|
87
|
+
"@cosmicdrift/kumiko-framework": "0.57.2",
|
|
88
|
+
"@cosmicdrift/kumiko-headless": "0.57.2",
|
|
89
|
+
"@cosmicdrift/kumiko-renderer": "0.57.2",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer-web": "0.57.2",
|
|
88
91
|
"@mollie/api-client": "^4.5.0",
|
|
89
92
|
"@node-rs/argon2": "^2.0.2",
|
|
90
93
|
"@types/nodemailer": "^8.0.0",
|
|
91
94
|
"clsx": "^2.1.1",
|
|
92
95
|
"lucide-react": "^1.14.0",
|
|
93
96
|
"marked": "^18.0.3",
|
|
94
|
-
"nodemailer": "^
|
|
97
|
+
"nodemailer": "^9.0.1",
|
|
95
98
|
"react": "^19.2.6",
|
|
96
99
|
"stripe": "^22.1.1",
|
|
97
100
|
"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.",
|
|
@@ -100,8 +100,14 @@ describe("ENV→app-override bridge — config:query:values", () => {
|
|
|
100
100
|
|
|
101
101
|
test("leak guard: inheritedToTenant:false hides the ENV app-override from a tenant", async () => {
|
|
102
102
|
const res = await stack.http.queryOk<Values>(ConfigQueries.values, {}, tenantAdmin);
|
|
103
|
+
expect(res[API_BASE]).toBeDefined();
|
|
103
104
|
expect(res[API_BASE]?.value).not.toBe("https://internal.example.com");
|
|
104
105
|
expect(res[API_BASE]?.source).not.toBe("app-override");
|
|
106
|
+
// Positive anchor: API_BASE has no keyDef.default → after redaction the key
|
|
107
|
+
// resolves as genuinely unset ("missing"), NOT absent for some other reason
|
|
108
|
+
// (e.g. an access-deny would drop the key entirely and leave the negative
|
|
109
|
+
// asserts above vacuously green).
|
|
110
|
+
expect(res[API_BASE]?.source).toBe("missing");
|
|
105
111
|
});
|
|
106
112
|
});
|
|
107
113
|
|
|
@@ -167,6 +167,44 @@ describe("config backing=secrets — read dispatch", () => {
|
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
describe("config backing=secrets — fail-loud when secrets unwired", () => {
|
|
171
|
+
// The PR's central safety promise: a backing="secrets" key throws loudly at
|
|
172
|
+
// request time when ctx.secrets is absent — it never silently degrades into
|
|
173
|
+
// a config_values write. One write exercises the set.write throw site; the
|
|
174
|
+
// resolver and reset.write guards share the identical `!ctx.secrets` shape.
|
|
175
|
+
test("set on a backing=secrets key throws internal_error when ctx.secrets is absent", async () => {
|
|
176
|
+
const unwired = await setupTestStack({
|
|
177
|
+
features: [createConfigFeature(), billingFeature],
|
|
178
|
+
extraContext: ({ registry }) => {
|
|
179
|
+
const resolver = createConfigResolver();
|
|
180
|
+
return {
|
|
181
|
+
configResolver: resolver,
|
|
182
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
183
|
+
// No `secrets` — the backing="secrets" path must fail loudly.
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await unsafePushTables(unwired.db, { configValuesTable, tenantSecretsTable });
|
|
188
|
+
await createEventsTable(unwired.db);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const err = await unwired.http.writeErr(
|
|
192
|
+
ConfigHandlers.set,
|
|
193
|
+
{ key: API_KEY, value: "sk-live-should-not-persist", scope: "system" },
|
|
194
|
+
systemAdmin,
|
|
195
|
+
);
|
|
196
|
+
expect(err.code).toBe("internal_error");
|
|
197
|
+
expect(err.httpStatus).toBe(500);
|
|
198
|
+
|
|
199
|
+
// It must NOT have silently fallen back to a config_values row.
|
|
200
|
+
const configRows = await selectMany(unwired.db, configValuesTable, { key: API_KEY });
|
|
201
|
+
expect(configRows).toHaveLength(0);
|
|
202
|
+
} finally {
|
|
203
|
+
await unwired.cleanup();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
170
208
|
describe("config backing=secrets — reset dispatch", () => {
|
|
171
209
|
test("reset clears the secret; the key falls back to unset", async () => {
|
|
172
210
|
await stack.http.writeOk(ConfigHandlers.reset, { key: API_KEY, scope: "system" }, systemAdmin);
|
|
@@ -34,6 +34,7 @@ const tenantAdmin = createTestUser({ id: 2 }); // roles ["Admin"], same tenant
|
|
|
34
34
|
const SMTP_HOST = "platform:config:smtp-host";
|
|
35
35
|
const SMTP_PASS = "platform:config:smtp-pass";
|
|
36
36
|
const LIST_HITS = "platform:config:list-hits";
|
|
37
|
+
const LIST_CAP = "platform:config:list-cap";
|
|
37
38
|
|
|
38
39
|
const configFeature = createConfigFeature();
|
|
39
40
|
|
|
@@ -60,6 +61,16 @@ const platformFeature = defineFeature("platform", (r) => {
|
|
|
60
61
|
write: access.systemAdmin,
|
|
61
62
|
read: access.admin,
|
|
62
63
|
}),
|
|
64
|
+
// Control: default inheritance WITH a set system-row value — proves a
|
|
65
|
+
// tenant receives the inherited system-row value (not just the
|
|
66
|
+
// keyDef.default fallback). The default (5) differs from the seeded
|
|
67
|
+
// system-row (42) so a broken pass-through can't masquerade as the
|
|
68
|
+
// default.
|
|
69
|
+
listCap: createSystemConfig("number", {
|
|
70
|
+
default: 5,
|
|
71
|
+
write: access.systemAdmin,
|
|
72
|
+
read: access.admin,
|
|
73
|
+
}),
|
|
63
74
|
},
|
|
64
75
|
});
|
|
65
76
|
});
|
|
@@ -90,6 +101,11 @@ beforeAll(async () => {
|
|
|
90
101
|
{ key: SMTP_PASS, value: "s3cr3t-password", scope: "system" },
|
|
91
102
|
systemAdmin,
|
|
92
103
|
);
|
|
104
|
+
await stack.http.writeOk(
|
|
105
|
+
ConfigHandlers.set,
|
|
106
|
+
{ key: LIST_CAP, value: 42, scope: "system" },
|
|
107
|
+
systemAdmin,
|
|
108
|
+
);
|
|
93
109
|
});
|
|
94
110
|
|
|
95
111
|
afterAll(async () => {
|
|
@@ -152,6 +168,19 @@ describe("inheritedToTenant redaction — config:query:cascade", () => {
|
|
|
152
168
|
);
|
|
153
169
|
expect(res[LIST_HITS]?.value).toBe(10);
|
|
154
170
|
});
|
|
171
|
+
|
|
172
|
+
test("control: a SET system-row value is inherited by tenants (not the default)", async () => {
|
|
173
|
+
const res = await stack.http.queryOk<Cascades>(
|
|
174
|
+
ConfigQueries.cascade,
|
|
175
|
+
{ keys: [LIST_CAP] },
|
|
176
|
+
tenantAdmin,
|
|
177
|
+
);
|
|
178
|
+
// 42 = seeded system-row value; would be 5 (keyDef.default) if pass-through
|
|
179
|
+
// for non-redacted keys were broken.
|
|
180
|
+
expect(res[LIST_CAP]?.value).toBe(42);
|
|
181
|
+
expect(systemLevel(res, LIST_CAP)?.value).toBe(42);
|
|
182
|
+
expect(systemLevel(res, LIST_CAP)?.hasValue).toBe(true);
|
|
183
|
+
});
|
|
155
184
|
});
|
|
156
185
|
|
|
157
186
|
describe("inheritedToTenant redaction — config:query:values", () => {
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
3
|
import { SUPPORTED_FIELD_TYPES } from "../constants";
|
|
4
|
-
import { createCustomFieldsFeature } from "../feature";
|
|
4
|
+
import { createCustomFieldsFeature, resolveFieldDefinitionListRoles } from "../feature";
|
|
5
5
|
import { defineFieldPayloadSchema, deleteFieldPayloadSchema } from "../schemas";
|
|
6
6
|
|
|
7
7
|
// B1 unit-tests: feature-shape, schema-validation, aggregate-id determinism.
|
|
@@ -156,14 +156,67 @@ describe("createCustomFieldsFeature access-options", () => {
|
|
|
156
156
|
expect(writeAccess(feature, "define-tenant-field")).toEqual(["TenantAdmin"]);
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
const feature = createCustomFieldsFeature({ fieldDefinitionListRoles: ["Admin", "Editor"] });
|
|
159
|
+
function listAccess(feature: ReturnType<typeof createCustomFieldsFeature>): readonly string[] {
|
|
161
160
|
const entry = Object.entries(feature.queryHandlers).find(([qn]) =>
|
|
162
161
|
qn.includes("field-definition:list"),
|
|
163
162
|
);
|
|
164
163
|
if (!entry) throw new Error("field-definition:list not registered");
|
|
165
164
|
const access = entry[1].access;
|
|
166
165
|
if (!access || !("roles" in access)) throw new Error("list-query has no roles");
|
|
167
|
-
|
|
166
|
+
return access.roles;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test("fieldDefinitionListRoles überschreibt den List-Query (FormSection-Lade-Pfad)", () => {
|
|
170
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionListRoles: ["Admin", "Editor"] });
|
|
171
|
+
expect(listAccess(feature)).toEqual(["Admin", "Editor"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// #334/2: valueWriteRoles ohne fieldDefinitionListRoles brach asymmetrisch —
|
|
175
|
+
// Save offen für App-Rollen, aber der List-Lade-Pfad blieb ["TenantAdmin"] →
|
|
176
|
+
// App-User bekamen access_denied, die FormSection lud nie. Die Value-Rollen
|
|
177
|
+
// erben jetzt in den List-Default (Union mit dem Default).
|
|
178
|
+
test("valueWriteRoles erbt in den List-Default wenn fieldDefinitionListRoles fehlt", () => {
|
|
179
|
+
const feature = createCustomFieldsFeature({ valueWriteRoles: ["Admin", "Editor"] });
|
|
180
|
+
const roles = listAccess(feature);
|
|
181
|
+
// Value-Writer können laden …
|
|
182
|
+
expect(roles).toContain("Admin");
|
|
183
|
+
expect(roles).toContain("Editor");
|
|
184
|
+
// … und Admins behalten den List-Zugriff.
|
|
185
|
+
expect(roles).toContain("TenantAdmin");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("explizite fieldDefinitionListRoles gewinnen über die valueWriteRoles-Vererbung", () => {
|
|
189
|
+
const feature = createCustomFieldsFeature({
|
|
190
|
+
valueWriteRoles: ["Admin", "Editor"],
|
|
191
|
+
fieldDefinitionListRoles: ["Viewer"],
|
|
192
|
+
});
|
|
193
|
+
expect(listAccess(feature)).toEqual(["Viewer"]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("resolveFieldDefinitionListRoles", () => {
|
|
198
|
+
test("nichts gesetzt → reiner Default", () => {
|
|
199
|
+
expect(resolveFieldDefinitionListRoles({})).toEqual(["TenantAdmin"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("valueWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
|
|
203
|
+
expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["Admin", "Editor"] })).toEqual([
|
|
204
|
+
"Admin",
|
|
205
|
+
"Editor",
|
|
206
|
+
"TenantAdmin",
|
|
207
|
+
]);
|
|
208
|
+
// TenantAdmin schon in valueWriteRoles → keine Dublette.
|
|
209
|
+
expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["TenantAdmin", "Editor"] })).toEqual(
|
|
210
|
+
["TenantAdmin", "Editor"],
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("explizite list-Rollen gewinnen immer (auch über valueWriteRoles)", () => {
|
|
215
|
+
expect(
|
|
216
|
+
resolveFieldDefinitionListRoles({
|
|
217
|
+
valueWriteRoles: ["Admin"],
|
|
218
|
+
fieldDefinitionListRoles: ["Viewer"],
|
|
219
|
+
}),
|
|
220
|
+
).toEqual(["Viewer"]);
|
|
168
221
|
});
|
|
169
222
|
});
|
|
@@ -171,11 +171,27 @@ export type CustomFieldsFeatureOptions = {
|
|
|
171
171
|
* das setzen, sonst ist der Value-Save für jeden App-User
|
|
172
172
|
* access_denied (Role-Naming-Drift). */
|
|
173
173
|
readonly valueWriteRoles?: readonly string[];
|
|
174
|
-
/** Rollen für custom-fields:query:field-definition:list — der
|
|
175
|
-
*
|
|
174
|
+
/** Rollen für custom-fields:query:field-definition:list — der Lade-Pfad
|
|
175
|
+
* der CustomFieldsFormSection. Default ["TenantAdmin"]. Wird valueWriteRoles
|
|
176
|
+
* gesetzt, dies aber NICHT, erben die Value-Rollen hier hinein (Union mit
|
|
177
|
+
* dem Default, damit Admins den List-Zugriff behalten) — sonst lädt die
|
|
178
|
+
* FormSection für Value-Writer nie (access_denied), während der Save-Pfad
|
|
179
|
+
* offen wäre (#334/2, asymmetrischer Bruch). */
|
|
176
180
|
readonly fieldDefinitionListRoles?: readonly string[];
|
|
177
181
|
};
|
|
178
182
|
|
|
183
|
+
// Der List-Pfad muss jeden abdecken, der Values schreiben darf — sonst lädt die
|
|
184
|
+
// FormSection nie. Explizite fieldDefinitionListRoles gewinnen; sonst: gesetzte
|
|
185
|
+
// valueWriteRoles erben in den List-Default (Union mit dem Default), ungesetzte
|
|
186
|
+
// → reiner Default.
|
|
187
|
+
export function resolveFieldDefinitionListRoles(
|
|
188
|
+
opts: Pick<CustomFieldsFeatureOptions, "valueWriteRoles" | "fieldDefinitionListRoles">,
|
|
189
|
+
): readonly string[] {
|
|
190
|
+
if (opts.fieldDefinitionListRoles !== undefined) return opts.fieldDefinitionListRoles;
|
|
191
|
+
if (opts.valueWriteRoles === undefined) return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
|
|
192
|
+
return [...new Set([...opts.valueWriteRoles, ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
180
196
|
// host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
|
|
181
197
|
// module-level-Singleton — kein neuer build pro Aufruf, was für consumer
|
|
@@ -200,8 +216,7 @@ export function createCustomFieldsFeature(
|
|
|
200
216
|
: defineTenantFieldHandler,
|
|
201
217
|
setHandler: createSetCustomFieldHandler(opts.valueWriteRoles),
|
|
202
218
|
clearHandler: createClearCustomFieldHandler(opts.valueWriteRoles),
|
|
203
|
-
fieldDefinitionListRoles:
|
|
204
|
-
opts.fieldDefinitionListRoles ?? DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
219
|
+
fieldDefinitionListRoles: resolveFieldDefinitionListRoles(opts),
|
|
205
220
|
}),
|
|
206
221
|
);
|
|
207
222
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { S3ProviderConfig } from "../s3-provider";
|
|
3
|
-
import { resolveForcePathStyle } from "../s3-provider";
|
|
3
|
+
import { createS3Provider, resolveForcePathStyle, resolveVirtualHostedStyle } from "../s3-provider";
|
|
4
4
|
|
|
5
5
|
const baseConfig: S3ProviderConfig = {
|
|
6
6
|
bucket: "b",
|
|
@@ -34,3 +34,63 @@ describe("resolveForcePathStyle", () => {
|
|
|
34
34
|
).toBe(false);
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
// virtualHostedStyle ist die Inversion, die createS3Provider an Bun.S3Client
|
|
39
|
+
// durchreicht (#175/2). Der `!` ist die stille Drift-Stelle: kippt er, picken
|
|
40
|
+
// Minio/R2 die falsche URL-Form ohne Compile- oder Runtime-Fehler.
|
|
41
|
+
describe("resolveVirtualHostedStyle (inverse of forcePathStyle)", () => {
|
|
42
|
+
const cases: ReadonlyArray<{ name: string; config: S3ProviderConfig }> = [
|
|
43
|
+
{ name: "no endpoint + no override", config: baseConfig },
|
|
44
|
+
{ name: "custom endpoint", config: { ...baseConfig, endpoint: "http://localhost:9000" } },
|
|
45
|
+
{ name: "explicit forcePathStyle true", config: { ...baseConfig, forcePathStyle: true } },
|
|
46
|
+
{
|
|
47
|
+
name: "custom endpoint + explicit false",
|
|
48
|
+
config: { ...baseConfig, endpoint: "http://localhost:9000", forcePathStyle: false },
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const { name, config } of cases) {
|
|
53
|
+
test(`${name} → strict inverse of resolveForcePathStyle`, () => {
|
|
54
|
+
expect(resolveVirtualHostedStyle(config)).toBe(!resolveForcePathStyle(config));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("AWS default (no endpoint) → virtual-host-style true", () => {
|
|
59
|
+
expect(resolveVirtualHostedStyle(baseConfig)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("Minio/R2 (custom endpoint) → virtual-host-style false (= path-style)", () => {
|
|
63
|
+
expect(resolveVirtualHostedStyle({ ...baseConfig, endpoint: "http://localhost:9000" })).toBe(
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// presign ist eine reine lokale Signier-Operation (HMAC, kein Netzwerk) →
|
|
70
|
+
// hermetisch testbar mit Dummy-Credentials. Beweist, dass Bun das
|
|
71
|
+
// contentDisposition-Feld tatsächlich als response-content-disposition-Query-
|
|
72
|
+
// Param signiert (#175/3) — sonst lieferte ein Download den UUID-Key statt des
|
|
73
|
+
// Dateinamens, lautlos.
|
|
74
|
+
describe("getSignedUrl contentDisposition", () => {
|
|
75
|
+
const provider = createS3Provider({
|
|
76
|
+
bucket: "b",
|
|
77
|
+
region: "us-east-1",
|
|
78
|
+
accessKeyId: "AKIAEXAMPLE",
|
|
79
|
+
secretAccessKey: "secret",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("signs response-content-disposition into the presigned URL", async () => {
|
|
83
|
+
const url = await provider.getSignedUrl?.("uuid-key.bin", 60, {
|
|
84
|
+
contentDisposition: 'attachment; filename="report.pdf"',
|
|
85
|
+
});
|
|
86
|
+
expect(url).toBeDefined();
|
|
87
|
+
const params = new URL(url ?? "").searchParams;
|
|
88
|
+
expect(params.get("response-content-disposition")).toBe('attachment; filename="report.pdf"');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("omits the param when no contentDisposition is passed", async () => {
|
|
92
|
+
const url = await provider.getSignedUrl?.("uuid-key.bin", 60);
|
|
93
|
+
expect(url).toBeDefined();
|
|
94
|
+
expect(new URL(url ?? "").searchParams.has("response-content-disposition")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -54,6 +54,14 @@ export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
|
54
54
|
return config.forcePathStyle ?? config.endpoint !== undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Bun's `virtualHostedStyle` is the inverse of the AWS-SDK `forcePathStyle`
|
|
58
|
+
// knob this config exposes: path-style ⇔ virtualHostedStyle=false. Exported +
|
|
59
|
+
// tested alongside resolveForcePathStyle because the inversion is exactly the
|
|
60
|
+
// seam that silently breaks Minio/R2 if the `!` ever drifts.
|
|
61
|
+
export function resolveVirtualHostedStyle(config: S3ProviderConfig): boolean {
|
|
62
|
+
return !resolveForcePathStyle(config);
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
export function createS3Provider(config: S3ProviderConfig): FileStorageProvider {
|
|
58
66
|
const client = new Bun.S3Client({
|
|
59
67
|
region: config.region,
|
|
@@ -61,9 +69,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
61
69
|
secretAccessKey: config.secretAccessKey,
|
|
62
70
|
bucket: config.bucket,
|
|
63
71
|
...(config.endpoint !== undefined && { endpoint: config.endpoint }),
|
|
64
|
-
|
|
65
|
-
// knob this config exposes: path-style ⇔ virtualHostedStyle=false.
|
|
66
|
-
virtualHostedStyle: !resolveForcePathStyle(config),
|
|
72
|
+
virtualHostedStyle: resolveVirtualHostedStyle(config),
|
|
67
73
|
});
|
|
68
74
|
|
|
69
75
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
3
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
5
|
import {
|
|
@@ -16,7 +17,7 @@ import { BRANDING_QN, BRANDING_QUERY_QN } from "../branding";
|
|
|
16
17
|
import { createManagedPagesCssFeature } from "../css-gate";
|
|
17
18
|
import { createManagedPagesFeature } from "../feature";
|
|
18
19
|
import { seedPage } from "../seeding";
|
|
19
|
-
import { pageEntity } from "../table";
|
|
20
|
+
import { type PageRow, pageEntity, pagesTable } from "../table";
|
|
20
21
|
|
|
21
22
|
const TENANT_A = "11111111-1111-4111-8111-111111111111";
|
|
22
23
|
const TENANT_B = "22222222-2222-4222-8222-222222222222";
|
|
@@ -286,6 +287,96 @@ describe("managed-pages :: set (Provisioning-API)", () => {
|
|
|
286
287
|
});
|
|
287
288
|
});
|
|
288
289
|
|
|
290
|
+
// The SystemAdmin-only `tenantIdOverride` cross-tenant write path had zero
|
|
291
|
+
// coverage (#382/2): not the happy path, not the access guard, not the
|
|
292
|
+
// documented `executorUser`-rebase fix (the override must rebase the executor's
|
|
293
|
+
// tenant or getStreamVersion runs against the wrong tenant → version_conflict
|
|
294
|
+
// on the second write).
|
|
295
|
+
describe("managed-pages :: set with tenantIdOverride (cross-tenant, SystemAdmin)", () => {
|
|
296
|
+
const sysAdmin = createTestUser({ id: 40, roles: ["SystemAdmin"] }); // distinct tenant
|
|
297
|
+
|
|
298
|
+
test("(a) SystemAdmin override writes the row under the TARGET tenant", async () => {
|
|
299
|
+
const res = await stack.http.writeOk<{ isNew: boolean }>(
|
|
300
|
+
"managed-pages:write:set",
|
|
301
|
+
{
|
|
302
|
+
slug: "sys-cross",
|
|
303
|
+
lang: "en",
|
|
304
|
+
title: "Cross-tenant by system",
|
|
305
|
+
body: "x",
|
|
306
|
+
published: true,
|
|
307
|
+
tenantIdOverride: TENANT_A,
|
|
308
|
+
},
|
|
309
|
+
sysAdmin,
|
|
310
|
+
);
|
|
311
|
+
expect(res).toMatchObject({ isNew: true });
|
|
312
|
+
|
|
313
|
+
// The persisted row carries TENANT_A — not the SystemAdmin's own tenant.
|
|
314
|
+
const row = await fetchOne<PageRow>(stack.db, pagesTable, {
|
|
315
|
+
tenantId: TENANT_A,
|
|
316
|
+
slug: "sys-cross",
|
|
317
|
+
lang: "en",
|
|
318
|
+
});
|
|
319
|
+
expect(row?.tenantId).toBe(TENANT_A);
|
|
320
|
+
expect(row?.title).toBe("Cross-tenant by system");
|
|
321
|
+
|
|
322
|
+
// End-to-end: it renders under a.* (TENANT_A) and is absent under b.*.
|
|
323
|
+
const aHtml = await (await stack.app.request("http://a.example.com/p/sys-cross")).text();
|
|
324
|
+
expect(aHtml).toContain("Cross-tenant by system");
|
|
325
|
+
const bRes = await stack.app.request("http://b.example.com/p/sys-cross");
|
|
326
|
+
expect(bRes.status).toBe(404);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("(b) non-SystemAdmin with tenantIdOverride → access_denied", async () => {
|
|
330
|
+
const error = await stack.http.writeErr(
|
|
331
|
+
"managed-pages:write:set",
|
|
332
|
+
{ slug: "sneak", lang: "en", title: "x", body: "y", tenantIdOverride: TENANT_B },
|
|
333
|
+
tenantAdmin, // TenantAdmin, NOT SystemAdmin
|
|
334
|
+
);
|
|
335
|
+
expectErrorIncludes(error, "access_denied");
|
|
336
|
+
expect(JSON.stringify(error)).toContain("tenant_override_requires_system_admin");
|
|
337
|
+
|
|
338
|
+
// The write was rejected — no row leaked into TENANT_B.
|
|
339
|
+
const leaked = await fetchOne<PageRow>(stack.db, pagesTable, {
|
|
340
|
+
tenantId: TENANT_B,
|
|
341
|
+
slug: "sneak",
|
|
342
|
+
lang: "en",
|
|
343
|
+
});
|
|
344
|
+
expect(leaked).toBeFalsy();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("(c) SystemAdmin override on an EXISTING page updates it — no version_conflict", async () => {
|
|
348
|
+
// First override-write creates the row under TENANT_A.
|
|
349
|
+
await stack.http.writeOk(
|
|
350
|
+
"managed-pages:write:set",
|
|
351
|
+
{
|
|
352
|
+
slug: "sys-rebase",
|
|
353
|
+
lang: "en",
|
|
354
|
+
title: "v1",
|
|
355
|
+
body: "a",
|
|
356
|
+
published: true,
|
|
357
|
+
tenantIdOverride: TENANT_A,
|
|
358
|
+
},
|
|
359
|
+
sysAdmin,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// Second override-write must hit the UPDATE path. Two prior bugs broke this:
|
|
363
|
+
// (1) the existing-check used the tenant-scoped ctx.db → blind to the target
|
|
364
|
+
// tenant's row → create → unique_violation; (2) even reaching update,
|
|
365
|
+
// getStreamVersion ran against the executor's tenant → version_conflict.
|
|
366
|
+
// The fix reads the existing row through the unscoped runner AND rebases the
|
|
367
|
+
// executor user to the override tenant.
|
|
368
|
+
const second = await stack.http.writeOk<{ isNew: boolean }>(
|
|
369
|
+
"managed-pages:write:set",
|
|
370
|
+
{ slug: "sys-rebase", lang: "en", title: "v2", body: "b", tenantIdOverride: TENANT_A },
|
|
371
|
+
sysAdmin,
|
|
372
|
+
);
|
|
373
|
+
expect(second).toMatchObject({ isNew: false });
|
|
374
|
+
|
|
375
|
+
const html = await (await stack.app.request("http://a.example.com/p/sys-rebase")).text();
|
|
376
|
+
expect(html).toContain("v2");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
289
380
|
describe("managed-pages :: Branding (Config + Render)", () => {
|
|
290
381
|
// config:write:set leitet tenantId aus user.tenantId ab → tenant-spezifische
|
|
291
382
|
// Admins, damit das Branding auf TENANT_A bzw. TENANT_B landet (Host a.*/b.*).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { z } from "zod";
|
|
@@ -57,7 +57,17 @@ export const setWrite = defineWriteHandler({
|
|
|
57
57
|
const executorUser =
|
|
58
58
|
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// ctx.db is tenant-scoped to the EXECUTING user (createTenantDb "tenant"
|
|
61
|
+
// mode). For a cross-tenant override that scope is wrong on BOTH the
|
|
62
|
+
// existing-check (blind to the target tenant's projection row → every
|
|
63
|
+
// re-provision retries as a create → unique_violation) AND the executor's
|
|
64
|
+
// stream reads (getStreamVersion/loadAggregate filtered to the executor's
|
|
65
|
+
// tenant → not_found/version_conflict). Re-scope a TenantDb to the resolved
|
|
66
|
+
// target tenant so reads and writes both land there. Safe: the override
|
|
67
|
+
// branch is SystemAdmin-gated above.
|
|
68
|
+
const scopedDb =
|
|
69
|
+
override !== undefined ? createTenantDb(db.raw, override as TenantId, "tenant") : db; // @cast-boundary engine-bridge
|
|
70
|
+
const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
|
|
61
71
|
tenantId,
|
|
62
72
|
slug: event.payload.slug,
|
|
63
73
|
lang: event.payload.lang,
|
|
@@ -81,7 +91,7 @@ export const setWrite = defineWriteHandler({
|
|
|
81
91
|
},
|
|
82
92
|
},
|
|
83
93
|
executorUser,
|
|
84
|
-
|
|
94
|
+
scopedDb,
|
|
85
95
|
);
|
|
86
96
|
if (!result.isSuccess) return result;
|
|
87
97
|
return {
|
|
@@ -102,7 +112,7 @@ export const setWrite = defineWriteHandler({
|
|
|
102
112
|
tenantId,
|
|
103
113
|
},
|
|
104
114
|
executorUser,
|
|
105
|
-
|
|
115
|
+
scopedDb,
|
|
106
116
|
);
|
|
107
117
|
if (!result.isSuccess) return result;
|
|
108
118
|
return {
|