@cosmicdrift/kumiko-framework 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/package.json +3 -2
  3. package/src/auth/__tests__/roles.test.ts +24 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/roles.ts +42 -0
  6. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  7. package/src/compliance/__tests__/profiles.test.ts +308 -0
  8. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  9. package/src/compliance/duration-spec.ts +44 -0
  10. package/src/compliance/index.ts +31 -0
  11. package/src/compliance/override-schema.ts +136 -0
  12. package/src/compliance/profiles.ts +427 -0
  13. package/src/compliance/sub-processors.ts +152 -0
  14. package/src/db/__tests__/big-int-field.test.ts +131 -0
  15. package/src/db/table-builder.ts +18 -1
  16. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  18. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  19. package/src/engine/boot-validator.ts +276 -0
  20. package/src/engine/define-feature.ts +39 -0
  21. package/src/engine/extension-names.ts +105 -0
  22. package/src/engine/extensions/user-data.ts +106 -0
  23. package/src/engine/factories.ts +15 -5
  24. package/src/engine/feature-ast/extractors.ts +40 -0
  25. package/src/engine/feature-ast/parse.ts +6 -0
  26. package/src/engine/feature-ast/patterns.ts +22 -0
  27. package/src/engine/feature-ast/render.ts +14 -0
  28. package/src/engine/index.ts +21 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
  30. package/src/engine/pattern-library/library.ts +36 -0
  31. package/src/engine/schema-builder.ts +8 -0
  32. package/src/engine/types/feature.ts +51 -0
  33. package/src/engine/types/fields.ts +134 -10
  34. package/src/engine/types/index.ts +3 -0
  35. package/src/files/__tests__/read-stream.test.ts +105 -0
  36. package/src/files/__tests__/write-stream.test.ts +233 -0
  37. package/src/files/__tests__/zip-stream.test.ts +357 -0
  38. package/src/files/in-memory-provider.ts +38 -0
  39. package/src/files/index.ts +3 -0
  40. package/src/files/local-provider.ts +58 -1
  41. package/src/files/types.ts +34 -6
  42. package/src/files/zip-stream.ts +251 -0
@@ -0,0 +1,152 @@
1
+ // Sub-Processor-Liste der Kumiko-Plattform.
2
+ //
3
+ // Auftragsverarbeiter im Sinne von DSGVO Art. 28 die Kumiko fuer den
4
+ // Plattform-Betrieb einsetzt. Wird oeffentlich exposed unter
5
+ // /api/compliance/sub-processors (JSON, Sprint 1)
6
+ // /api/compliance/sub-processors.rss (RSS, Sprint 1)
7
+ // kumiko.so/subprocessors (HTML, Marketing-Repo)
8
+ //
9
+ // Tenant-Admins muessen ueber Add/Change/Remove informiert werden mit
10
+ // Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
11
+ // in Sprint 1 (compliance-profiles).
12
+ //
13
+ // Quelle: docs/plans/datenschutz/compliance-profiles.md "Sub-Processor-
14
+ // Management" + docs/plans/datenschutz/legal-artifacts.md.
15
+
16
+ /**
17
+ * Bundle-Tier-Marker fuer SubProcessor.appliesTo. "all-tiers" matched
18
+ * unabhaengig vom konkreten Tenant-Bundle (z.B. Hetzner als Hosting-
19
+ * Provider). Konkrete Tier-Namen koennen per-Tenant gefiltert werden.
20
+ */
21
+ export type BundleTier = "all-tiers" | "standard" | "business" | "enterprise";
22
+
23
+ /**
24
+ * Beschreibt einen Auftragsverarbeiter (Art. 28 DSGVO) der von Kumiko
25
+ * fuer den Plattform-Betrieb eingesetzt wird.
26
+ */
27
+ export interface SubProcessor {
28
+ /** Voller juristischer Name. */
29
+ readonly name: string;
30
+ /** Was macht der Sub-Processor fuer uns? */
31
+ readonly purpose: string;
32
+ /** Sitz / Datenverarbeitungs-Region. */
33
+ readonly region: string;
34
+ /** Link zum Auftragsverarbeitungsvertrag (DPA/AVV). */
35
+ readonly dpa: string;
36
+ /** Wann wurde der Sub-Processor zu unserer Plattform hinzugefuegt? */
37
+ readonly addedAt: string;
38
+ /**
39
+ * Welche Bundle-Tiers nutzen diesen Sub-Processor?
40
+ * Tenants nicht-betroffener Tiers brauchen keine Notification bei
41
+ * Aenderungen.
42
+ */
43
+ readonly appliesTo: readonly BundleTier[];
44
+ /**
45
+ * Standard Contractual Clauses (SCC) fuer Drittlandsuebermittlung
46
+ * abgeschlossen. Pflicht fuer alle Sub-Processors mit Sitz ausserhalb
47
+ * EU/EWR.
48
+ */
49
+ readonly sccRequired?: boolean;
50
+ /**
51
+ * Tenant muss explizit aktivieren (z.B. AI-Feature). Ohne Opt-In
52
+ * werden keine Daten an diesen Sub-Processor gesendet.
53
+ */
54
+ readonly optInOnly?: boolean;
55
+ /**
56
+ * Business Associate Agreement fuer HIPAA-Customers verfuegbar.
57
+ * Relevant nur fuer hipaa-healthcare Compliance-Profile.
58
+ */
59
+ readonly hipaaBaaAvailable?: boolean;
60
+ /**
61
+ * Geplant aber noch nicht aktiv (Vorbereitung fuer kommenden Sprint).
62
+ * Wird im Sub-Processor-Endpoint als "planned"-Sektion separat
63
+ * ausgegeben damit Tenants schon Lead-Time bekommen.
64
+ */
65
+ readonly status?: "active" | "planned";
66
+ }
67
+
68
+ /**
69
+ * Plattform-weite Sub-Processor-Liste.
70
+ *
71
+ * Reihenfolge: nach addedAt (aelteste zuerst). Bei Aenderungen
72
+ * Snapshot-Test im Test-File explizit updaten — der detektiert
73
+ * stille Aenderungen.
74
+ */
75
+ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
76
+ {
77
+ name: "Hetzner Online GmbH",
78
+ purpose: "Cloud-Infrastructure (CNPG-Postgres-Cluster, K8s-Pods, Volumes, Object-Storage)",
79
+ region: "EU (Germany)",
80
+ dpa: "https://www.hetzner.com/legal/dpa",
81
+ addedAt: "2024-01-01",
82
+ appliesTo: ["all-tiers"],
83
+ status: "active",
84
+ },
85
+ {
86
+ name: "Cloudflare, Inc.",
87
+ purpose: "DNS, CDN, DDoS-Protection, WAF",
88
+ region: "Global (US-headquartered)",
89
+ dpa: "https://www.cloudflare.com/cloudflare-customer-dpa",
90
+ addedAt: "2024-01-01",
91
+ appliesTo: ["all-tiers"],
92
+ sccRequired: true,
93
+ status: "active",
94
+ },
95
+ {
96
+ name: "Sendinblue SAS (Brevo)",
97
+ purpose: "Transactional Email Delivery",
98
+ region: "EU (France)",
99
+ dpa: "https://www.brevo.com/legal/dpa/",
100
+ addedAt: "2024-03-01",
101
+ appliesTo: ["standard", "business", "enterprise"],
102
+ status: "active",
103
+ },
104
+ {
105
+ name: "Heinlein Hosting (Mailbox.org)",
106
+ purpose: "Marketing Email Delivery",
107
+ region: "EU (Germany)",
108
+ dpa: "https://mailbox.org/de/datenschutzerklaerung",
109
+ addedAt: "2024-03-01",
110
+ appliesTo: ["all-tiers"],
111
+ status: "active",
112
+ },
113
+ {
114
+ name: "Anthropic PBC",
115
+ purpose: "AI Model Inference (L2 Composition Layer, AI-Foundation)",
116
+ region: "US",
117
+ dpa: "https://www.anthropic.com/legal/dpa",
118
+ addedAt: "2026-06-01",
119
+ appliesTo: ["business", "enterprise"],
120
+ sccRequired: true,
121
+ optInOnly: true,
122
+ hipaaBaaAvailable: true,
123
+ status: "planned",
124
+ },
125
+ {
126
+ name: "Stripe, Inc.",
127
+ purpose: "Payment Processing (Subscription-Stripe-Plugin)",
128
+ region: "Global (US-headquartered)",
129
+ dpa: "https://stripe.com/legal/dpa",
130
+ addedAt: "2026-06-01",
131
+ appliesTo: ["all-tiers"],
132
+ sccRequired: true,
133
+ status: "planned",
134
+ },
135
+ ];
136
+
137
+ /**
138
+ * Filter helper — nur aktive Sub-Processors (kein status="planned").
139
+ * Genutzt vom oeffentlichen JSON-Endpoint (Sprint 1).
140
+ */
141
+ export function getActiveSubProcessors(): readonly SubProcessor[] {
142
+ return KUMIKO_SUB_PROCESSORS.filter((sp) => sp.status !== "planned");
143
+ }
144
+
145
+ /**
146
+ * Filter helper — nur geplante Sub-Processors (status="planned"). Werden
147
+ * separat als "demnaechst aktiv"-Sektion ausgegeben damit Tenant-Admins
148
+ * schon Lead-Time fuer Compliance-Profile-Konfiguration bekommen.
149
+ */
150
+ export function getPlannedSubProcessors(): readonly SubProcessor[] {
151
+ return KUMIKO_SUB_PROCESSORS.filter((sp) => sp.status === "planned");
152
+ }
@@ -0,0 +1,131 @@
1
+ // Unit-Tests fuer den BigInt-Field-Type — Atom 1a aus dem User-Data-
2
+ // Rights Async-Export-Plan.
3
+ //
4
+ // Pinst:
5
+ // - createBigIntField liefert FieldDefinition.type === "bigInt"
6
+ // - buildDrizzleTable mappt auf bigint(name, mode:"number"), nicht
7
+ // integer (32-bit) → kein silent 2 GB-Cap
8
+ // - Zod-Schema akzeptiert int + safe-integer + lehnt non-int + Float
9
+ // + non-safe-integer ab
10
+ // - required + sortable + filterable + default reisen durch
11
+ //
12
+ // Echter DB-Roundtrip-Test (Insert >2^31, Select, identisch zurueck)
13
+ // kommt mit Atom 1b sobald `exportJobEntity.bytesWritten` auf bigInt
14
+ // migriert ist + die existing integration-Tests die Tabelle wieder
15
+ // einrichten — pinst dort auf realer Postgres + Drizzle-customType-
16
+ // Path statt parallel-mock hier.
17
+
18
+ import { describe, expect, test } from "vitest";
19
+ import { createBigIntField, createEntity, createNumberField } from "../../engine";
20
+ import { buildInsertSchema } from "../../engine/schema-builder";
21
+ import { buildDrizzleTable } from "../table-builder";
22
+
23
+ function colByName(table: ReturnType<typeof buildDrizzleTable>, dbName: string) {
24
+ for (const col of Object.values(table) as Array<{
25
+ name?: string;
26
+ notNull?: boolean;
27
+ columnType?: string;
28
+ dataType?: string;
29
+ }>) {
30
+ if (col && typeof col === "object" && col.name === dbName) return col;
31
+ }
32
+ throw new Error(`Column ${dbName} not found in table`);
33
+ }
34
+
35
+ describe("createBigIntField factory", () => {
36
+ test("liefert FieldDef mit type='bigInt' + default required=false", () => {
37
+ const f = createBigIntField();
38
+ expect(f.type).toBe("bigInt");
39
+ expect(f.required).toBe(false);
40
+ });
41
+
42
+ test("Overrides reisen durch (required, sortable, filterable, default)", () => {
43
+ const f = createBigIntField({
44
+ required: true,
45
+ sortable: true,
46
+ filterable: true,
47
+ default: 42,
48
+ });
49
+ expect(f.required).toBe(true);
50
+ expect(f.sortable).toBe(true);
51
+ expect(f.filterable).toBe(true);
52
+ expect(f.default).toBe(42);
53
+ });
54
+ });
55
+
56
+ describe("buildDrizzleTable — bigInt-Mapping", () => {
57
+ test("bigInt-Spalte ist DISTINCT von number-Spalte (number=integer/32-bit, bigInt=bigint/64-bit)", () => {
58
+ const entity = createEntity({
59
+ fields: {
60
+ smallCount: createNumberField({}),
61
+ bigCount: createBigIntField({}),
62
+ },
63
+ });
64
+ const table = buildDrizzleTable("counters", entity);
65
+
66
+ const small = colByName(table, "small_count");
67
+ const big = colByName(table, "big_count");
68
+
69
+ // PgInteger vs PgBigint sind unterschiedliche columnType-Klassen in
70
+ // Drizzle — der genaue String ist Drizzle-Internal aber MUSS
71
+ // unterschiedlich sein, sonst geht der ganze 64-bit-Punkt verloren.
72
+ expect(small.columnType).not.toBe(big.columnType);
73
+ });
74
+
75
+ test("required bigInt wird NOT NULL", () => {
76
+ const entity = createEntity({
77
+ fields: {
78
+ requiredBig: createBigIntField({ required: true }),
79
+ optionalBig: createBigIntField({}),
80
+ },
81
+ });
82
+ const table = buildDrizzleTable("t", entity);
83
+ expect(colByName(table, "required_big").notNull).toBe(true);
84
+ expect(colByName(table, "optional_big").notNull).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe("buildInsertSchema — bigInt-Validation", () => {
89
+ test("akzeptiert safe-integer-Werte inkl. >2^31", () => {
90
+ const entity = createEntity({
91
+ fields: { bytesWritten: createBigIntField({ required: true }) },
92
+ });
93
+ const schema = buildInsertSchema(entity);
94
+
95
+ // 2^31 = 2_147_483_648 — Klassisches integer-Overflow-Pattern.
96
+ expect(schema.parse({ bytesWritten: 2_147_483_648 })).toEqual({
97
+ bytesWritten: 2_147_483_648,
98
+ });
99
+ // 2^50 — weit ueber integer, klar in bigInt-Territorium.
100
+ expect(schema.parse({ bytesWritten: 2 ** 50 })).toEqual({
101
+ bytesWritten: 2 ** 50,
102
+ });
103
+ });
104
+
105
+ test("lehnt Float ab (silent-Truncation-Schutz)", () => {
106
+ const entity = createEntity({
107
+ fields: { count: createBigIntField({ required: true }) },
108
+ });
109
+ const schema = buildInsertSchema(entity);
110
+ expect(() => schema.parse({ count: 1.5 })).toThrow();
111
+ });
112
+
113
+ test("lehnt non-safe-integer ab (>2^53)", () => {
114
+ const entity = createEntity({
115
+ fields: { count: createBigIntField({ required: true }) },
116
+ });
117
+ const schema = buildInsertSchema(entity);
118
+ // 2^53 = 9_007_199_254_740_992 ist Number.MAX_SAFE_INTEGER.
119
+ // Werte ueber dem Cap koennen nicht round-trip-en ohne Praezisions-
120
+ // Verlust — Zod's .safe() greift hier.
121
+ expect(() => schema.parse({ count: Number.MAX_SAFE_INTEGER + 2 })).toThrow();
122
+ });
123
+
124
+ test("default-Wert reist in Zod-Schema durch", () => {
125
+ const entity = createEntity({
126
+ fields: { count: createBigIntField({ default: 100 }) },
127
+ });
128
+ const schema = buildInsertSchema(entity);
129
+ expect(schema.parse({})).toEqual({ count: 100 });
130
+ });
131
+ });
@@ -8,6 +8,7 @@ import type {
8
8
  } from "../engine/types";
9
9
  import { assertUnreachable } from "../utils";
10
10
  import {
11
+ bigint,
11
12
  boolean,
12
13
  index,
13
14
  instant,
@@ -25,6 +26,7 @@ import {
25
26
  type ColumnBuilder =
26
27
  | ReturnType<typeof text>
27
28
  | ReturnType<typeof integer>
29
+ | ReturnType<typeof bigint>
28
30
  | ReturnType<typeof boolean>
29
31
  | ReturnType<typeof moneyAmount>
30
32
  | ReturnType<typeof jsonb>
@@ -92,6 +94,15 @@ function fieldToColumns(
92
94
  const col = integer(snakeName);
93
95
  return { [name]: field.required ? col.notNull() : col };
94
96
  }
97
+ case "bigInt": {
98
+ // 64-bit-Integer fuer Audit-Counter, Byte-Sizes, Cumulative-Sums.
99
+ // mode:"number" liefert JS-`number` (sicher bis 2^53 ≈ 9 PB) statt
100
+ // JS-`bigint` — JSON-serialisierbar, Frontend-tauglich. Wer >2^53
101
+ // braucht (Astronomie-Astronomie), nutzt einen Text-Field mit
102
+ // eigenem Codec.
103
+ const col = bigint(snakeName, { mode: "number" });
104
+ return { [name]: field.required ? col.notNull() : col };
105
+ }
95
106
  case "reference":
96
107
  // Tier 2.7e-3: FK-Style UUID-Spalte. Multi-Mode (Tier 2.7e-Multi)
97
108
  // speichert UUIDs als jsonb-Array<string>. Single-Mode bleibt
@@ -467,7 +478,13 @@ export function buildDrizzleTable<E extends EntityDefinition>(
467
478
  def.name ?? `${tableName}_${def.columns.map((c) => toSnakeCase(c)).join("_")}_${suffix}`;
468
479
  const builder = def.unique === true ? uniqueIndex(indexName) : index(indexName);
469
480
  // biome-ignore lint/suspicious/noExplicitAny: drizzle's .on(...cols) is variadic generic
470
- indexes.push((builder.on as any)(...cols));
481
+ let chain = (builder.on as any)(...cols);
482
+ if (def.where !== undefined) {
483
+ // Partial-Index: drizzle's IndexBuilder.where(SQL) emittiert das
484
+ // `WHERE <condition>` ans Ende der `CREATE [UNIQUE] INDEX`-DDL.
485
+ chain = chain.where(def.where);
486
+ }
487
+ indexes.push(chain);
471
488
  }
472
489
  return indexes;
473
490
  },
@@ -0,0 +1,142 @@
1
+ // Boot-Validator-Tests fuer r.exposesApi / r.usesApi (S0.4).
2
+ //
3
+ // Pflicht-Validierungen (Error / throw):
4
+ // - r.usesApi(name) ohne passenden r.exposesApi(name) → throw
5
+ // - r.usesApi(name) ohne r.requires(providerFeature) → throw
6
+ // - r.exposesApi(name) zweimal in einem Feature → throw
7
+ // - Globale Doppel-Exposure (zwei Features, gleicher Name) → throw
8
+ //
9
+ // Soft-Warning (console.warn):
10
+ // - Feature ruft eigene exposesApi via usesApi (Refactor-Leftover)
11
+
12
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
13
+ import { validateBoot } from "../boot-validator";
14
+ import { defineFeature } from "../define-feature";
15
+
16
+ describe("validateBoot — r.exposesApi / r.usesApi", () => {
17
+ let warnSpy: ReturnType<typeof vi.spyOn>;
18
+
19
+ beforeEach(() => {
20
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
21
+ });
22
+
23
+ afterEach(() => {
24
+ warnSpy.mockRestore();
25
+ });
26
+
27
+ test("matching exposesApi/usesApi with requires() passes", () => {
28
+ const provider = defineFeature("compliance-profiles", (r) => {
29
+ r.exposesApi("compliance.forTenant");
30
+ });
31
+ const consumer = defineFeature("user-data-rights", (r) => {
32
+ r.requires("compliance-profiles");
33
+ r.usesApi("compliance.forTenant");
34
+ });
35
+ expect(() => validateBoot([provider, consumer])).not.toThrow();
36
+ });
37
+
38
+ test("matching exposesApi/usesApi with optionalRequires() passes", () => {
39
+ const provider = defineFeature("compliance-profiles", (r) => {
40
+ r.exposesApi("compliance.forTenant");
41
+ });
42
+ const consumer = defineFeature("user-data-rights", (r) => {
43
+ r.optionalRequires("compliance-profiles");
44
+ r.usesApi("compliance.forTenant");
45
+ });
46
+ expect(() => validateBoot([provider, consumer])).not.toThrow();
47
+ });
48
+
49
+ test("usesApi without any exposer throws with known-list", () => {
50
+ const consumer = defineFeature("user-data-rights", (r) => {
51
+ r.usesApi("compliance.forTenant");
52
+ });
53
+ expect(() => validateBoot([consumer])).toThrow(
54
+ /r\.usesApi\("compliance\.forTenant"\) but no feature exposes that API/,
55
+ );
56
+ });
57
+
58
+ test("usesApi with typo throws and lists known APIs", () => {
59
+ const provider = defineFeature("compliance-profiles", (r) => {
60
+ r.exposesApi("compliance.forTenant");
61
+ });
62
+ const consumer = defineFeature("user-data-rights", (r) => {
63
+ r.requires("compliance-profiles");
64
+ r.usesApi("compliance.fortenant"); // typo: lowercase t
65
+ });
66
+ expect(() => validateBoot([provider, consumer])).toThrow(
67
+ /Known exposed APIs: compliance\.forTenant/,
68
+ );
69
+ });
70
+
71
+ test("usesApi exists but missing requires() throws", () => {
72
+ const provider = defineFeature("compliance-profiles", (r) => {
73
+ r.exposesApi("compliance.forTenant");
74
+ });
75
+ const consumer = defineFeature("user-data-rights", (r) => {
76
+ // missing r.requires("compliance-profiles")
77
+ r.usesApi("compliance.forTenant");
78
+ });
79
+ expect(() => validateBoot([provider, consumer])).toThrow(
80
+ /not in requires\/optionalRequires\. Add r\.requires\("compliance-profiles"\)/,
81
+ );
82
+ });
83
+
84
+ test("exposesApi twice in same feature throws", () => {
85
+ expect(() =>
86
+ defineFeature("dup", (r) => {
87
+ r.exposesApi("api.foo");
88
+ r.exposesApi("api.foo");
89
+ }),
90
+ ).toThrow(/r\.exposesApi\("api\.foo"\) called twice/);
91
+ });
92
+
93
+ test("two features expose the same API throws on boot", () => {
94
+ const a = defineFeature("a", (r) => {
95
+ r.exposesApi("shared.api");
96
+ });
97
+ const b = defineFeature("b", (r) => {
98
+ r.exposesApi("shared.api");
99
+ });
100
+ expect(() => validateBoot([a, b])).toThrow(
101
+ /Cross-feature API "shared\.api" exposed by both "a" and "b"/,
102
+ );
103
+ });
104
+
105
+ test("self-exposure (feature uses its own exposed API) warns", () => {
106
+ const f = defineFeature("self-loop", (r) => {
107
+ r.exposesApi("self.api");
108
+ r.usesApi("self.api");
109
+ });
110
+ validateBoot([f]);
111
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
112
+ String(args[0]).includes("typically a refactor leftover"),
113
+ );
114
+ expect(matchingWarn).toBeDefined();
115
+ });
116
+
117
+ test("feature with no API surface boots clean (regression guard)", () => {
118
+ const plain = defineFeature("plain", (r) => {
119
+ r.requires();
120
+ });
121
+ expect(() => validateBoot([plain])).not.toThrow();
122
+ });
123
+
124
+ test("global double-exposure throws before consumer-resolution kicks in", () => {
125
+ // Edge-case: zwei Features exposen denselben Namen UND ein drittes
126
+ // Feature ruft den Namen. Erwartet: Doppel-Exposure-Error wirft im
127
+ // Pre-Walk (validateBoot) BEVOR validateApiExposureMatching laeuft.
128
+ const a = defineFeature("provider-a", (r) => {
129
+ r.exposesApi("shared.api");
130
+ });
131
+ const b = defineFeature("provider-b", (r) => {
132
+ r.exposesApi("shared.api");
133
+ });
134
+ const consumer = defineFeature("consumer", (r) => {
135
+ r.requires("provider-a");
136
+ r.usesApi("shared.api");
137
+ });
138
+ expect(() => validateBoot([a, b, consumer])).toThrow(
139
+ /Cross-feature API "shared\.api" exposed by both/,
140
+ );
141
+ });
142
+ });