@cosmicdrift/kumiko-bundled-features 0.56.0 → 0.57.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 +1 -1
- package/src/config/__tests__/config.integration.test.ts +56 -1
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +63 -0
- package/src/config/handlers/set.write.ts +9 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +25 -0
- package/src/managed-pages/branding.ts +38 -7
- package/src/subscription-stripe/__tests__/runtime.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.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>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import {
|
|
5
5
|
createEncryptionProvider,
|
|
6
6
|
type DbConnection,
|
|
@@ -370,6 +370,61 @@ describe("Settings-Hub system-scope write by a human SystemAdmin", () => {
|
|
|
370
370
|
expect(await sysAccessor()("integration:config:system-secret")).toBe("sk_live_roundtrip");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
// Prod scenario: the value already EXISTS (Stripe screen with stored keys),
|
|
374
|
+
// SystemAdmin clicks Speichern → set.write takes the executor.update path,
|
|
375
|
+
// not create. That path was never covered → version-conflict surfaced only
|
|
376
|
+
// in prod. Three consecutive re-saves catch a stale-version regression.
|
|
377
|
+
test("re-saving an existing plain config key (update path) does not version-conflict", async () => {
|
|
378
|
+
for (const value of [false, true, false]) {
|
|
379
|
+
await stack.http.writeOk(
|
|
380
|
+
ConfigHandlers.set,
|
|
381
|
+
{ key: "integration:config:billing-live", value, scope: "system" },
|
|
382
|
+
systemAdmin,
|
|
383
|
+
);
|
|
384
|
+
expect(await sysAccessor()("integration:config:billing-live")).toBe(value);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("re-saving an existing secrets-backed key (update path) does not version-conflict", async () => {
|
|
389
|
+
for (const value of ["sk_live_v2", "sk_live_v3"]) {
|
|
390
|
+
await stack.http.writeOk(
|
|
391
|
+
ConfigHandlers.set,
|
|
392
|
+
{ key: "integration:config:system-secret", value, scope: "system" },
|
|
393
|
+
systemAdmin,
|
|
394
|
+
);
|
|
395
|
+
expect(await sysAccessor()("integration:config:system-secret")).toBe(value);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("save survives a projection/stream version desync (the prod cut-over symptom)", async () => {
|
|
400
|
+
// Reproduces admin.publicstatus.eu: a config value whose read-row version
|
|
401
|
+
// drifted from its event-stream version (migration wrote the row outside
|
|
402
|
+
// the event flow). With optimistic locking this version-conflicts on every
|
|
403
|
+
// save. The handler now skips the lock and appends at the real stream
|
|
404
|
+
// version, so the save succeeds AND the projection resyncs.
|
|
405
|
+
await stack.http.writeOk(
|
|
406
|
+
ConfigHandlers.set,
|
|
407
|
+
{ key: "integration:config:billing-live", value: true, scope: "system" },
|
|
408
|
+
systemAdmin,
|
|
409
|
+
);
|
|
410
|
+
// Corrupt the projection version so it no longer matches the stream.
|
|
411
|
+
await asRawClient(db).unsafe(
|
|
412
|
+
"UPDATE read_config_values SET version = version + 5 WHERE key = 'integration:config:billing-live'",
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// Two consecutive saves: the first proves the lock is bypassed, the second
|
|
416
|
+
// proves the projection actually resynced (a stale version wouldn't drift
|
|
417
|
+
// back into conflict).
|
|
418
|
+
for (const value of [false, true]) {
|
|
419
|
+
await stack.http.writeOk(
|
|
420
|
+
ConfigHandlers.set,
|
|
421
|
+
{ key: "integration:config:billing-live", value, scope: "system" },
|
|
422
|
+
systemAdmin,
|
|
423
|
+
);
|
|
424
|
+
expect(await sysAccessor()("integration:config:billing-live")).toBe(value);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
373
428
|
test("a plain tenant Admin is denied the privileged key (not via system-only)", async () => {
|
|
374
429
|
const error = await stack.http.writeErr(
|
|
375
430
|
ConfigHandlers.set,
|
|
@@ -211,6 +211,69 @@ describe("prepareConfigWrite", () => {
|
|
|
211
211
|
if (!result.ok) throw new Error("unreachable");
|
|
212
212
|
expect(result.scope).toBe(ConfigScopes.system);
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
// #396: a tenant self-service key (admin-editable) that provisioning /
|
|
216
|
+
// migration must ALSO set via ctx.systemWriteAs. access.withSystem(admin)
|
|
217
|
+
// adds SYSTEM_ROLE without collapsing the key to system-only.
|
|
218
|
+
describe("access.withSystem (provisionable tenant key)", () => {
|
|
219
|
+
const provisionableKey = createTenantConfig("text", {
|
|
220
|
+
write: access.withSystem(access.admin),
|
|
221
|
+
});
|
|
222
|
+
const registry = registryStub({ "ns:config:branding-title": provisionableKey });
|
|
223
|
+
|
|
224
|
+
test("the system actor (systemWriteAs roles) may provision it", () => {
|
|
225
|
+
const result = prepareConfigWrite({
|
|
226
|
+
registry,
|
|
227
|
+
user: userStub([SYSTEM_ROLE]),
|
|
228
|
+
key: "ns:config:branding-title",
|
|
229
|
+
});
|
|
230
|
+
expect(result.ok).toBe(true);
|
|
231
|
+
if (!result.ok) throw new Error("unreachable");
|
|
232
|
+
expect(result.keyDef).toBe(provisionableKey);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a tenant admin still self-services it (NOT collapsed to system-only)", () => {
|
|
236
|
+
const result = prepareConfigWrite({
|
|
237
|
+
registry,
|
|
238
|
+
user: userStub(["TenantAdmin"]),
|
|
239
|
+
key: "ns:config:branding-title",
|
|
240
|
+
});
|
|
241
|
+
expect(result.ok).toBe(true);
|
|
242
|
+
if (!result.ok) throw new Error("unreachable");
|
|
243
|
+
expect(result.scope).toBe(ConfigScopes.tenant);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("an unrelated role is still denied (generic, not system-only)", () => {
|
|
247
|
+
const result = prepareConfigWrite({
|
|
248
|
+
registry,
|
|
249
|
+
user: userStub(["ReadOnly"]),
|
|
250
|
+
key: "ns:config:branding-title",
|
|
251
|
+
});
|
|
252
|
+
expect(result.ok).toBe(false);
|
|
253
|
+
if (result.ok) throw new Error("unreachable");
|
|
254
|
+
expect(result.failure.error.code).toBe("access_denied");
|
|
255
|
+
expect(result.failure.error.i18nKey).not.toBe("config.errors.systemOnly");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("access.withSystem", () => {
|
|
261
|
+
test("prepends SYSTEM_ROLE and preserves the composed roles", () => {
|
|
262
|
+
expect(access.withSystem(access.admin)).toEqual([
|
|
263
|
+
SYSTEM_ROLE,
|
|
264
|
+
"TenantAdmin",
|
|
265
|
+
"Admin",
|
|
266
|
+
"SystemAdmin",
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("composes arbitrary custom roles (studio's own role lists)", () => {
|
|
271
|
+
expect(access.withSystem(access.roles("Editor", "Owner"))).toEqual([
|
|
272
|
+
SYSTEM_ROLE,
|
|
273
|
+
"Editor",
|
|
274
|
+
"Owner",
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
214
277
|
});
|
|
215
278
|
|
|
216
279
|
describe("validateBounds", () => {
|
|
@@ -95,6 +95,14 @@ export const setWrite = defineWriteHandler({
|
|
|
95
95
|
const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
|
|
96
96
|
|
|
97
97
|
if (existing) {
|
|
98
|
+
// skipOptimisticLock: config is single-writer operator state, not a
|
|
99
|
+
// collaboratively-edited aggregate — last-write-wins is the intended
|
|
100
|
+
// semantics. More importantly, the optimistic check compares the
|
|
101
|
+
// PROJECTION version (existing.version) against the event-stream
|
|
102
|
+
// version; if those drift (a migration/seed that wrote the read-row
|
|
103
|
+
// outside the normal event flow — e.g. the Stripe config cut-over),
|
|
104
|
+
// every save would version-conflict forever. Appending at the real
|
|
105
|
+
// stream version resyncs the projection and self-heals the drift.
|
|
98
106
|
const result = await executor.update(
|
|
99
107
|
{
|
|
100
108
|
id: existing.id,
|
|
@@ -103,6 +111,7 @@ export const setWrite = defineWriteHandler({
|
|
|
103
111
|
},
|
|
104
112
|
event.user,
|
|
105
113
|
db,
|
|
114
|
+
{ skipOptimisticLock: true },
|
|
106
115
|
);
|
|
107
116
|
if (!result.isSuccess) return result;
|
|
108
117
|
} else {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
4
|
import {
|
|
4
5
|
createTestUser,
|
|
@@ -396,6 +397,30 @@ describe("managed-pages :: Branding (Config + Render)", () => {
|
|
|
396
397
|
expect(htmlB).not.toContain("cdn-a.example.com");
|
|
397
398
|
expect(htmlB).not.toContain("Acme A");
|
|
398
399
|
});
|
|
400
|
+
|
|
401
|
+
// #396 — the actual gate: a provisioning/migration seed sets a tenant
|
|
402
|
+
// branding key via the system executor. createSystemUser(tenant) is the
|
|
403
|
+
// exact identity ctx.systemWriteAs injects (roles=[SYSTEM_ROLE]); stack.
|
|
404
|
+
// dispatcher is the same command path systemWriteAs drives. Before the
|
|
405
|
+
// access.withSystem(access.admin) write-role, this returned access_denied
|
|
406
|
+
// (forcing the publicstatus migration onto raw SQL). Drives the REAL
|
|
407
|
+
// config:write:set handler end-to-end and reads the value back per tenant —
|
|
408
|
+
// a unit test on checkWriteAccess alone wouldn't prove the handler accepts it.
|
|
409
|
+
test("system executor provisions a branding key (systemWriteAs path), read back per tenant", async () => {
|
|
410
|
+
const res = await stack.dispatcher.write(
|
|
411
|
+
"config:write:set",
|
|
412
|
+
{ key: BRANDING_QN.title, value: "Provisioned by system" },
|
|
413
|
+
createSystemUser(TENANT_A),
|
|
414
|
+
);
|
|
415
|
+
expect(res.isSuccess).toBe(true);
|
|
416
|
+
|
|
417
|
+
const branding = await stack.http.queryOk<{ title: string }>(BRANDING_QUERY_QN, {}, adminA);
|
|
418
|
+
expect(branding.title).toBe("Provisioned by system");
|
|
419
|
+
|
|
420
|
+
// The provisioned value lands on TENANT_A only — not leaked to TENANT_B.
|
|
421
|
+
const brandingB = await stack.http.queryOk<{ title: string }>(BRANDING_QUERY_QN, {}, adminB);
|
|
422
|
+
expect(brandingB.title).not.toBe("Provisioned by system");
|
|
423
|
+
});
|
|
399
424
|
});
|
|
400
425
|
|
|
401
426
|
// Eigener Stack mit allowCustomCss:true + dem Companion-Toggle-Feature. Der
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
access,
|
|
2
3
|
type ConfigAccessor,
|
|
3
4
|
type ConfigKeyDefinition,
|
|
4
5
|
createTenantConfig,
|
|
@@ -14,8 +15,14 @@ import { type BrandingTokens, EMPTY_BRANDING } from "../page-render";
|
|
|
14
15
|
// Write-validated: accent-color must be a CSS hex, logo/site URLs must be
|
|
15
16
|
// https. The configEdit screen dispatches `config:write:set` per key, which
|
|
16
17
|
// runs the keyDef.pattern gate (set.write.ts → validatePattern). `read: all`
|
|
17
|
-
// (scope default) so the anonymous public-render path can read them
|
|
18
|
-
//
|
|
18
|
+
// (scope default) so the anonymous public-render path can read them.
|
|
19
|
+
//
|
|
20
|
+
// `write: access.withSystem(access.admin)` — tenant admins self-service via
|
|
21
|
+
// configEdit AND the system actor (ctx.systemWriteAs) may provision/migrate
|
|
22
|
+
// these keys. The scope default (`access.admin`, admin-only) has no system
|
|
23
|
+
// path, so a continuity migration could not set them (issue #396). Adding
|
|
24
|
+
// SYSTEM_ROLE keeps the keys human-writable (checkWriteAccess collapses to
|
|
25
|
+
// system-only only when system is the SOLE writer), so self-service survives.
|
|
19
26
|
//
|
|
20
27
|
// CONTINUITY (Phase 5 — Prod trap): these keys land under
|
|
21
28
|
// `managed-pages:config:branding-*`, NOT the live `publicstatus:config:
|
|
@@ -55,15 +62,39 @@ export const MANAGED_PAGES_CSS_FEATURE = "managed-pages-css";
|
|
|
55
62
|
// site-url`. BRANDING_QN below is the single source those QN strings are read
|
|
56
63
|
// from (configEdit screen + render read); the integration test pins write-
|
|
57
64
|
// target == read-source end-to-end.
|
|
65
|
+
// Admin self-service + system provisioning (see write-access note above, #396).
|
|
66
|
+
const BRANDING_WRITE = access.withSystem(access.admin);
|
|
67
|
+
|
|
58
68
|
export const BRANDING_KEYS = {
|
|
59
|
-
brandingTitle: createTenantConfig("text", {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
brandingTitle: createTenantConfig("text", {
|
|
70
|
+
default: "",
|
|
71
|
+
pattern: TITLE_PATTERN,
|
|
72
|
+
write: BRANDING_WRITE,
|
|
73
|
+
}),
|
|
74
|
+
brandingDescription: createTenantConfig("text", {
|
|
75
|
+
default: "",
|
|
76
|
+
pattern: DESCRIPTION_PATTERN,
|
|
77
|
+
write: BRANDING_WRITE,
|
|
78
|
+
}),
|
|
79
|
+
brandingSiteUrl: createTenantConfig("text", {
|
|
80
|
+
default: "",
|
|
81
|
+
pattern: HTTPS_PATTERN,
|
|
82
|
+
write: BRANDING_WRITE,
|
|
83
|
+
}),
|
|
84
|
+
brandingAccentColor: createTenantConfig("text", {
|
|
85
|
+
default: "",
|
|
86
|
+
pattern: HEX_PATTERN,
|
|
87
|
+
write: BRANDING_WRITE,
|
|
88
|
+
}),
|
|
89
|
+
brandingLogoUrl: createTenantConfig("text", {
|
|
90
|
+
default: "",
|
|
91
|
+
pattern: HTTPS_PATTERN,
|
|
92
|
+
write: BRANDING_WRITE,
|
|
93
|
+
}),
|
|
64
94
|
brandingLayoutPreset: createTenantConfig("select", {
|
|
65
95
|
default: "centered",
|
|
66
96
|
options: LAYOUT_PRESETS,
|
|
97
|
+
write: BRANDING_WRITE,
|
|
67
98
|
}),
|
|
68
99
|
} satisfies Record<string, ConfigKeyDefinition>;
|
|
69
100
|
|
|
@@ -20,7 +20,7 @@ const WEBHOOK_SECRET_HANDLE: ConfigKeyHandle<"text"> = {
|
|
|
20
20
|
type: "text",
|
|
21
21
|
};
|
|
22
22
|
const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
23
|
-
name: "subscription-stripe:config:
|
|
23
|
+
name: "subscription-stripe:config:billing-live",
|
|
24
24
|
type: "boolean",
|
|
25
25
|
};
|
|
26
26
|
|