@cosmicdrift/kumiko-bundled-features 0.59.1 → 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 +2 -1
- 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/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__/feature.test.ts +34 -0
- package/src/tags/__tests__/tags.integration.test.ts +66 -0
- package/src/tags/constants.ts +11 -2
- package/src/tags/feature.ts +26 -21
- package/src/tags/handlers/assign-tag.write.ts +4 -6
- package/src/tags/handlers/create-tag.write.ts +4 -6
- package/src/tags/handlers/remove-tag.write.ts +4 -6
- package/src/tags/index.ts +1 -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-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,6 +26,7 @@
|
|
|
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",
|
|
@@ -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", () => {
|
|
@@ -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 {
|
|
@@ -5,22 +5,40 @@
|
|
|
5
5
|
// (das deckt der Integration-Test ab).
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
|
-
import
|
|
8
|
+
import {
|
|
9
|
+
type ConfigKeyHandle,
|
|
10
|
+
type HandlerContext,
|
|
11
|
+
qn,
|
|
12
|
+
toKebab,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
14
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
15
|
import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
11
16
|
import Stripe from "stripe";
|
|
17
|
+
import {
|
|
18
|
+
STRIPE_API_KEY_CONFIG,
|
|
19
|
+
STRIPE_BILLING_LIVE_CONFIG,
|
|
20
|
+
STRIPE_WEBHOOK_SECRET_CONFIG,
|
|
21
|
+
SUBSCRIPTION_STRIPE_FEATURE,
|
|
22
|
+
} from "../constants";
|
|
12
23
|
import { createStripeClientCache, createStripeRuntimes } from "../runtime";
|
|
13
24
|
|
|
25
|
+
// Handle-Namen aus den kanonischen Konstanten + demselben Qualifier ableiten,
|
|
26
|
+
// den r.config zur Build-Zeit anwendet (define-feature.ts: qn(toKebab(feature),
|
|
27
|
+
// "config", toKebab(shortKey))). Eine hand-redeklarierte Fixture konnte still
|
|
28
|
+
// von der Produktion driften (#421/2) — diese Ableitung macht das unmöglich.
|
|
29
|
+
const configHandleName = (shortKey: string): string =>
|
|
30
|
+
qn(toKebab(SUBSCRIPTION_STRIPE_FEATURE), "config", toKebab(shortKey));
|
|
31
|
+
|
|
14
32
|
const API_KEY_HANDLE: ConfigKeyHandle<"text"> = {
|
|
15
|
-
name:
|
|
33
|
+
name: configHandleName(STRIPE_API_KEY_CONFIG),
|
|
16
34
|
type: "text",
|
|
17
35
|
};
|
|
18
36
|
const WEBHOOK_SECRET_HANDLE: ConfigKeyHandle<"text"> = {
|
|
19
|
-
name:
|
|
37
|
+
name: configHandleName(STRIPE_WEBHOOK_SECRET_CONFIG),
|
|
20
38
|
type: "text",
|
|
21
39
|
};
|
|
22
40
|
const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
23
|
-
name:
|
|
41
|
+
name: configHandleName(STRIPE_BILLING_LIVE_CONFIG),
|
|
24
42
|
type: "boolean",
|
|
25
43
|
};
|
|
26
44
|
|
|
@@ -42,13 +60,36 @@ function stubSecrets(values: Record<string, string>): SecretsContext {
|
|
|
42
60
|
};
|
|
43
61
|
}
|
|
44
62
|
|
|
63
|
+
/** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
|
|
64
|
+
* parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
|
|
65
|
+
* un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
|
|
66
|
+
* backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
|
|
67
|
+
* still Müll liefern. */
|
|
68
|
+
function rawSecretsStub(values: Record<string, string>): SecretsContext {
|
|
69
|
+
const nameOf = (k: string | { readonly name: string }): string =>
|
|
70
|
+
typeof k === "string" ? k : k.name;
|
|
71
|
+
return {
|
|
72
|
+
get: async (_tenantId, key) => {
|
|
73
|
+
const value = values[nameOf(key)];
|
|
74
|
+
return value === undefined ? undefined : createSecret(value); // RAW, not JSON
|
|
75
|
+
},
|
|
76
|
+
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
77
|
+
set: async () => undefined,
|
|
78
|
+
delete: async () => false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
45
82
|
/** Minimaler HandlerContext-Stub mit nur den Feldern, die die ctx-
|
|
46
83
|
* Resolution liest (secrets, _userId für audit, config). */
|
|
47
84
|
function stubCtx(opts: { secrets?: SecretsContext; billingLive?: boolean }): HandlerContext {
|
|
48
85
|
return {
|
|
49
86
|
secrets: opts.secrets,
|
|
50
87
|
_userId: "tester",
|
|
51
|
-
|
|
88
|
+
// Key-aware: antwortet NUR auf das billing-live-Handle. Liest
|
|
89
|
+
// assertBillingLive versehentlich einen anderen Config-Key, kommt undefined
|
|
90
|
+
// zurück → Gate schließt → der "passes when true"-Test schlägt fehl (#421/3).
|
|
91
|
+
config: async (handle: ConfigKeyHandle<"boolean">) =>
|
|
92
|
+
handle.name === BILLING_LIVE_HANDLE.name ? opts.billingLive : undefined,
|
|
52
93
|
} as unknown as HandlerContext; // @cast-boundary test-stub — partial ctx
|
|
53
94
|
}
|
|
54
95
|
|
|
@@ -116,6 +157,19 @@ describe("StripeCtxRuntime.clientForCtx", () => {
|
|
|
116
157
|
const ctx = stubCtx({ secrets: stubSecrets({}) });
|
|
117
158
|
await expect(rt.ctx.clientForCtx(ctx)).rejects.toBeInstanceOf(UnconfiguredError);
|
|
118
159
|
});
|
|
160
|
+
|
|
161
|
+
test("throws loudly on a malformed (non-JSON) stored credential (#393/2)", async () => {
|
|
162
|
+
// The store round-trips backing:"secrets" values JSON-encoded; a raw,
|
|
163
|
+
// un-quoted value reaching parseStoredSecret means corruption — it must
|
|
164
|
+
// throw, not silently fall through to undefined/fallback.
|
|
165
|
+
const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
|
|
166
|
+
const ctx = stubCtx({
|
|
167
|
+
secrets: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
|
|
168
|
+
});
|
|
169
|
+
await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
|
|
170
|
+
/Invalid JSON in subscription-stripe credential/,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
119
173
|
});
|
|
120
174
|
|
|
121
175
|
// =============================================================================
|