@cosmicdrift/kumiko-framework 0.157.2 → 0.159.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  8. package/src/api/api-constants.ts +4 -0
  9. package/src/api/auth-middleware.ts +48 -59
  10. package/src/api/auth-routes.ts +83 -17
  11. package/src/api/index.ts +8 -4
  12. package/src/api/jwt.ts +148 -7
  13. package/src/api/pii-leak-guard.ts +5 -2
  14. package/src/api/server.ts +19 -5
  15. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  16. package/src/bun-db/query.ts +34 -2
  17. package/src/consumer-cli.ts +87 -0
  18. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  19. package/src/crypto/blind-index.ts +8 -4
  20. package/src/crypto/event-pii.ts +1 -0
  21. package/src/crypto/pii-field-encryption.ts +49 -15
  22. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  25. package/src/db/blind-index-cleanup.ts +3 -1
  26. package/src/db/connection.ts +3 -11
  27. package/src/db/encryption.ts +2 -3
  28. package/src/db/entity-table-meta-types.ts +92 -0
  29. package/src/db/entity-table-meta.ts +16 -90
  30. package/src/db/queries/backfill-pii.ts +1 -0
  31. package/src/db/queries/event-consumer.ts +35 -2
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  33. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  34. package/src/engine/__tests__/define-roles.test.ts +21 -0
  35. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  36. package/src/engine/__tests__/store-table.test.ts +12 -0
  37. package/src/engine/boot-validator/action-wiring.ts +1 -1
  38. package/src/engine/boot-validator/boot-check.ts +21 -0
  39. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  40. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  41. package/src/engine/boot-validator/index.ts +3 -9
  42. package/src/engine/boot-validator/screens.ts +1 -1
  43. package/src/engine/define-feature.ts +1 -0
  44. package/src/engine/define-handler.ts +10 -91
  45. package/src/engine/entity-handlers.ts +15 -27
  46. package/src/engine/feature-builder-state.ts +3 -0
  47. package/src/engine/feature-config-events-jobs.ts +1 -1
  48. package/src/engine/feature-entity-handlers.ts +1 -1
  49. package/src/engine/feature-ui-extensions.ts +5 -1
  50. package/src/engine/field-helpers.ts +31 -0
  51. package/src/engine/handler-helpers.ts +26 -0
  52. package/src/engine/hook-helpers.ts +14 -0
  53. package/src/engine/index.ts +2 -2
  54. package/src/engine/ownership.ts +22 -76
  55. package/src/engine/registry-validate.ts +1 -1
  56. package/src/engine/screen-helpers.ts +54 -0
  57. package/src/engine/tier-resolver-extension.ts +3 -2
  58. package/src/engine/types/define-handler.ts +94 -0
  59. package/src/engine/types/entity-handlers.ts +30 -0
  60. package/src/engine/types/event-type-map.ts +1 -37
  61. package/src/engine/types/feature.ts +45 -0
  62. package/src/engine/types/fields.ts +19 -31
  63. package/src/engine/types/handlers.ts +7 -26
  64. package/src/engine/types/hooks.ts +1 -15
  65. package/src/engine/types/http-route.ts +1 -72
  66. package/src/engine/types/identifiers.ts +1 -47
  67. package/src/engine/types/index.ts +34 -9
  68. package/src/engine/types/ownership.ts +83 -0
  69. package/src/engine/types/relations.ts +1 -51
  70. package/src/engine/types/screen.ts +0 -46
  71. package/src/engine/types/target-ref.ts +1 -21
  72. package/src/engine/types/tree-node.ts +1 -129
  73. package/src/entrypoint/index.ts +2 -2
  74. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  75. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  76. package/src/event-store/event-store.ts +28 -32
  77. package/src/event-store/events-schema.ts +1 -10
  78. package/src/event-store/index.ts +3 -2
  79. package/src/event-store/types.ts +22 -0
  80. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  81. package/src/files/file-handle.ts +2 -19
  82. package/src/i18n/required-surface-keys.ts +1 -1
  83. package/src/logging/types.ts +1 -7
  84. package/src/observability/types/index.ts +1 -29
  85. package/src/observability/types/metric.ts +1 -56
  86. package/src/observability/types/provider.ts +1 -32
  87. package/src/observability/types/span.ts +1 -58
  88. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  89. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  90. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  91. package/src/pipeline/dispatch-shared.ts +12 -2
  92. package/src/pipeline/entity-cache.ts +2 -33
  93. package/src/pipeline/event-consumer-state.ts +28 -3
  94. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  95. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  96. package/src/pipeline/event-dispatcher.ts +27 -1
  97. package/src/pipeline/system-hooks.ts +7 -0
  98. package/src/search/types.ts +1 -39
  99. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  100. package/src/secrets/__tests__/envelope.test.ts +1 -1
  101. package/src/secrets/envelope-cipher.ts +13 -39
  102. package/src/stack/__tests__/event-collector.test.ts +42 -0
  103. package/src/testing/__tests__/late-bound.test.ts +25 -0
  104. package/src/testing/__tests__/wait-for.test.ts +53 -0
  105. package/src/testing/boot-validator-fixture.ts +1 -1
  106. package/src/testing/file-provider-contract.ts +84 -0
  107. package/src/testing/handler-context.ts +1 -1
  108. package/src/testing/index.ts +1 -0
  109. package/src/time/geo-tz.ts +1 -32
  110. package/src/ui-types/index.ts +7 -7
@@ -0,0 +1,83 @@
1
+ // --- Ownership ---
2
+ // Pure types for the declarative Claims → Access bridge. Runtime (from(),
3
+ // matchesRule(), buildOwnershipClause()) stays in engine/ownership.ts, which
4
+ // re-exports these for backwards compatibility.
5
+
6
+ import type { SessionUser } from "./handlers";
7
+
8
+ // Parameterised SQL fragment — produced by buildOwnershipClause + by the
9
+ // WhereRule escape-hatch. Caller weaves `sqlText` into a larger statement,
10
+ // renumbering placeholders if needed (shiftParams in engine/ownership.ts).
11
+ export type SqlFragment = {
12
+ readonly sqlText: string;
13
+ readonly params: readonly unknown[];
14
+ };
15
+
16
+ // Reference spec supported by `from()`:
17
+ // "user:id" → user.id
18
+ // "user:tenantId" → user.tenantId (rarely needed — TenantDb scopes anyway)
19
+ // "claim:<featureName>:<key>" → user.claims["<featureName>:<key>"]
20
+ //
21
+ // The string form is keyed so the framework can look up the referenced
22
+ // Registry entry at boot (Claim-QN exists? Column type compatible?). A typed
23
+ // object form would force features to import each other's handles — the
24
+ // whole point of H.2's unified path is string-based references, no imports.
25
+ export type OwnershipRef = string;
26
+
27
+ // Resolved during `from()` — the parser eagerly splits the prefix so the
28
+ // runtime evaluator avoids string-parsing on every row. `kind` drives the
29
+ // evaluator branch; the rest is the resolved metadata.
30
+ export type FromRuleKind = "user" | "claim";
31
+
32
+ export type FromRule = {
33
+ readonly kind: "from";
34
+ readonly refKind: FromRuleKind;
35
+ // For "user:id" → "id"; for "user:tenantId" → "tenantId".
36
+ // For "claim:<featureName>:<key>" → "<featureName>:<key>" (the full QN,
37
+ // which is exactly the key under which the JWT stores the value).
38
+ readonly refPath: string;
39
+ // Row-column to match against. For claim rules defaults to the claim's
40
+ // shortName (second segment of the claim QN). For user-rules the column
41
+ // is always explicit.
42
+ readonly column: string;
43
+ };
44
+
45
+ // Context passed to a WhereRule escape-hatch. The author returns a SqlFragment
46
+ // whose placeholders start at `paramStart` ($N, $N+1, ...); the framework
47
+ // concatenates the fragment into the larger query.
48
+ export type WhereRuleContext<TTable = unknown> = {
49
+ readonly table: TTable;
50
+ readonly tableName: string;
51
+ readonly paramStart: number;
52
+ };
53
+
54
+ export type WhereRule<TTable = unknown> = {
55
+ readonly kind: "where";
56
+ readonly where: (user: SessionUser, ctx: WhereRuleContext<TTable>) => SqlFragment;
57
+ };
58
+
59
+ // "all" collapses to a primitive so map authors can write `Admin: "all"`
60
+ // without importing a helper.
61
+ export type OwnershipRule = "all" | FromRule | WhereRule;
62
+
63
+ // Per-role map: every key is a role name, value is the rule that role
64
+ // satisfies to pass the access check.
65
+ export type OwnershipMap = Readonly<Record<string, OwnershipRule>>;
66
+
67
+ // Result of buildOwnershipClause. The discriminant lets the caller handle
68
+ // the three outcomes without inspecting SQL internals:
69
+ //
70
+ // "pass" → user is unrestricted. Run the query as-is.
71
+ // "empty" → user has a role mapped but no rule accepts any row (missing
72
+ // claim, empty array, role not in map). Skip the DB call entirely
73
+ // — returning [] is equivalent and avoids a pointless roundtrip.
74
+ // "sql" → apply the parameterised fragment as an AND on the query.
75
+ // Caller is responsible for renumbering placeholders when
76
+ // concatenating with other fragments (see `shiftParams`).
77
+ //
78
+ // "empty" vs. "pass" is the critical distinction for a safe default:
79
+ // undefined/pass = allow, empty = deny-by-construction.
80
+ export type OwnershipClause =
81
+ | { readonly kind: "pass" }
82
+ | { readonly kind: "empty" }
83
+ | { readonly kind: "sql"; readonly sqlText: string; readonly params: readonly unknown[] };
@@ -1,51 +1 @@
1
- import type { OnDeleteStrategy } from "../constants";
2
-
3
- // --- Relations ---
4
-
5
- export type BelongsToRelation = {
6
- readonly type: "belongsTo";
7
- readonly target: string;
8
- readonly foreignKey: string;
9
- readonly searchInclude?: readonly string[];
10
- // onDelete is declared on the parent-side (hasMany / manyToMany) because
11
- // that's where the "what happens to my children?" decision lives. A
12
- // belongsTo node just points at a parent — the parent's onDelete drives
13
- // the cleanup.
14
- };
15
-
16
- export type HasManyRelation = {
17
- readonly type: "hasMany";
18
- readonly target: string;
19
- readonly foreignKey: string;
20
- readonly onDelete?: OnDeleteStrategy;
21
- // When true, a nested payload under this relation's key (e.g.
22
- // `{ tasks: [{ ... }] }` on a `project:create` write) is auto-expanded
23
- // into child writes: parent first, then one child-write per entry with
24
- // the foreign key set to the parent's new id — all in the same TX.
25
- // Opt-in (default false) so legacy hasMany relations that were declared
26
- // purely for cascade-delete or UI-nav semantics don't silently gain a
27
- // client-writable path. Children are never inferred from payload-shape
28
- // alone; only relations with this flag unlock nested-write.
29
- //
30
- // Scope v1: depth=1, create-only, hasMany-only. Update-nested,
31
- // delete-nested, and belongsTo/m2m auto-expansion are explicit future
32
- // work — when they arrive, they'll take the same flag so the opt-in
33
- // stays a single, consistent surface.
34
- readonly nestedWrite?: boolean;
35
- };
36
-
37
- export type ManyToManyRelation = {
38
- readonly type: "manyToMany";
39
- readonly target: string;
40
- readonly through: {
41
- readonly table: string;
42
- readonly sourceKey: string;
43
- readonly targetKey: string;
44
- };
45
- readonly searchInclude?: readonly string[];
46
- readonly onDelete?: OnDeleteStrategy;
47
- };
48
-
49
- export type RelationDefinition = BelongsToRelation | HasManyRelation | ManyToManyRelation;
50
-
51
- export type EntityRelations = Readonly<Record<string, RelationDefinition>>;
1
+ export * from "@cosmicdrift/kumiko-types/relations";
@@ -539,10 +539,6 @@ export type EditExtensionSection = {
539
539
  readonly component: PlatformComponent;
540
540
  };
541
541
 
542
- export function isExtensionEditSection(section: EditSectionSpec): section is EditExtensionSection {
543
- return section.kind === "extension";
544
- }
545
-
546
542
  export type EditLayout = {
547
543
  readonly sections: readonly EditSectionSpec[];
548
544
  };
@@ -749,45 +745,3 @@ export type ScreenDefinition = (
749
745
  | ConfigEditScreenDefinition
750
746
  | CustomScreenDefinition
751
747
  ) & { readonly nav?: ScreenNavSugar };
752
-
753
- // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
754
- // authors who branch on the three FieldRenderer variants without manual
755
- // "format" in renderer checks.
756
- export function isFormatSpec(r: unknown): r is FormatSpec {
757
- return typeof r === "object" && r !== null && "format" in r && typeof r.format === "string";
758
- }
759
-
760
- // Collapse the string-shorthand into the object form. Both the boot-validator
761
- // and (later) ui-core's view-model builder iterate over fields/columns — the
762
- // helper keeps that loop from growing two branches everywhere.
763
- export function normalizeListColumn(c: ListColumnSpec): Exclude<ListColumnSpec, string> {
764
- const col = typeof c === "string" ? { field: c } : c;
765
- if (
766
- typeof process !== "undefined" &&
767
- process.env.NODE_ENV !== "production" &&
768
- col.renderer !== undefined &&
769
- typeof col.renderer === "function"
770
- ) {
771
- // biome-ignore lint/suspicious/noConsole: dev-only warning
772
- console.warn(
773
- `[kumiko] normalizeListColumn: Feld "${col.field}" hat einen Funktions-Renderer — dieser wird von JSON.stringify verworfen. Bitte auf FormatSpec ({ format: "..." }) migrieren.`,
774
- );
775
- }
776
- return col;
777
- }
778
-
779
- /** Evaluates a declarative FieldCondition against the current row/form
780
- * values. THE single implementation — renderer (row-action visibility),
781
- * headless view-model (visible/readOnly/required) and render-edit
782
- * (form-condition closures) reuse it; three hand-rolled copies had
783
- * already drifted in shape. */
784
- export function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean {
785
- if (typeof cond === "boolean") return cond;
786
- const val = values[cond.field];
787
- if ("eq" in cond) return val === cond.eq;
788
- return val !== cond.ne;
789
- }
790
-
791
- export function normalizeEditField(f: EditFieldSpec): Exclude<EditFieldSpec, string> {
792
- return typeof f === "string" ? { field: f } : f;
793
- }
@@ -1,21 +1 @@
1
- // TargetRef runtime-Repräsentation eines typed buildTarget-Outputs.
2
- // Wird vom Visual-Tree-Component (renderer-web) an einen Target-Resolver
3
- // dispatcht; der Resolver findet die Editor-Maske via featureId.
4
- //
5
- // **Compile-time-Safety:** TargetRef wird niemals hand-getippt. Stattdessen
6
- // erzeugt der typed buildTarget-Builder (engine/build-target.ts) einen
7
- // TargetRef, dessen action + args gegen die treeActions-Map des Ziel-
8
- // Features validiert sind.
9
- //
10
- // **Runtime:** args sind hier untyped (Record<string, unknown>), weil
11
- // TargetRef die erased-runtime-Version ist. Der Resolver kennt das
12
- // Ziel-Feature und kann args entsprechend casten — ähnlich wie Event-
13
- // Payloads im Event-Store.
14
- //
15
- // Siehe docs/plans/architecture/visual-tree.md A5.
16
-
17
- export type TargetRef = {
18
- readonly featureId: string;
19
- readonly action: string;
20
- readonly args?: Readonly<Record<string, unknown>>;
21
- };
1
+ export * from "@cosmicdrift/kumiko-types/target-ref";
@@ -1,129 +1 @@
1
- // TreeNode single Knoten im Client-navProvider-Tree. Provider liefern
2
- // entweder statische readonly TreeNode[] oder dynamische TreeChildrenSubscribe.
3
- //
4
- // **Mental-Modell** (VS-Code-Explorer):
5
- // [icon] [label] [...hover-actions]
6
- // optional ein target zum Klicken (öffnet Editor-Maske via
7
- // Target-Resolver) und optional children als nested tree.
8
- //
9
- // **State** markiert Visual-Modus für Skeleton-Pattern:
10
- // - "filled" (default) — schwarz, Knoten hat Inhalt
11
- // - "stub" — hellgrau, existing aber leer (Designer-Stub-File)
12
- // - "empty" — Platzhalter für "+ create"-Affordance
13
- // - "loading" — Children werden gerade aufgelöst
14
- // - "error" — Provider hat Fehler emittiert
15
- // Provider die kein Skeleton-Pattern brauchen müssen state nicht setzen.
16
- //
17
- // **Subscribe-Form** für dynamic Children: Provider erhält emit(),
18
- // gibt unsubscribe() zurück. Initial-Emit synchron oder async, weitere
19
- // Emits beliebig oft (z.B. wenn Entity-Row neu erscheint via SSE).
20
- // Spielt natürlich mit existing SSE-Frame: ein Provider kann intern
21
- // auf Entity-Update-Events abonnieren und bei Änderung emit() aufrufen.
22
-
23
- import type { TargetRef } from "./target-ref";
24
-
25
- export type TreeNodeState = "filled" | "stub" | "empty" | "loading" | "error";
26
-
27
- export type TreeAction = {
28
- // Icon-Key — vom Renderer-Icon-Registry interpretiert. Konvention
29
- // matched NavDefinition.icon: unbekannte Icons surface als missing-icon
30
- // im UI, nicht als Boot-Failure.
31
- readonly icon: string;
32
- // i18n-Translation-Key oder roher String. Vom Renderer aufgelöst, Engine
33
- // behandelt opak (mirrors NavDefinition.label, WorkspaceDefinition.label).
34
- readonly label: string;
35
- // Klick-Ziel der Action. Pflicht — Action ohne target ist semantisch
36
- // sinnlos (Hover-Icon das nichts tut).
37
- readonly target: TargetRef;
38
- };
39
-
40
- export type TreeNode = {
41
- // i18n-Translation-Key oder roher String. Vom Renderer beim Rendern
42
- // aufgelöst (siehe TreeAction.label).
43
- readonly label: string;
44
- // Optional. Icon links neben dem Label. Selbe Konvention wie
45
- // TreeAction.icon — Renderer-Icon-Registry-Lookup.
46
- readonly icon?: string;
47
- // Visueller State für Skeleton-Pattern. Default "filled" (kein Eintrag
48
- // ⇒ schwarz/normal). Wert-Semantik im Header-Comment dieser Datei.
49
- readonly state?: TreeNodeState;
50
- // Optional Klick-Ziel. Fehlt → reiner Container-Knoten (nur ausklappbar,
51
- // nicht klickbar). Vorhanden → Klick öffnet die Editor-Maske via
52
- // Target-Resolver in renderer-web.
53
- readonly target?: TargetRef;
54
- // Hover-Actions rechts (Add/Refresh/Delete/etc.). Werden in der
55
- // Sidebar-Row erst bei Hover sichtbar — VS-Code-Pattern. Engine
56
- // ordnet die Actions in der Reihenfolge an, in der sie hier stehen.
57
- readonly actions?: readonly TreeAction[];
58
- // Statische Children oder dynamic Subscribe-Function. Subscribe wird
59
- // erst beim Ausklappen aufgerufen (lazy); die Function-Form erlaubt
60
- // SSE-gefütterte Live-Updates wenn neue Entity-Rows reinkommen.
61
- readonly children?: readonly TreeNode[] | TreeChildrenSubscribe;
62
- // Provider-deklarierte „+ create"-Action für Knoten mit `state: "empty"`.
63
- // Tree-Component zeigt automatisch ein „+"-Icon und dispatcht
64
- // `createAction.target` bei Klick — Provider weiß was „leer befüllen"
65
- // für ihn bedeutet (z.B. „neuer Page-Slug" vs „neue Entity-Row"),
66
- // Convention könnte das nicht raten. Konsistent zu `state` (auch
67
- // Provider-explizit). Siehe visual-tree.md V.1.1-Decision D3.
68
- readonly createAction?: TreeAction;
69
- };
70
-
71
- // Subscribe<T> — Provider implementiert: emit(initial); ...emit(updated);
72
- // und gibt unsubscribe-Function zurück. Caller (Tree-Component) ruft
73
- // unsubscribe auf wenn Knoten unmounted/eingeklappt wird.
74
- //
75
- // **V.1.4 emitError**: optional callback für async-error-Pfade (fetch-
76
- // fail, SSE-disconnect). Provider die explizit Errors signalisieren
77
- // wollen rufen `emitError(e)` statt empty-emit; VisualTree zeigt
78
- // Error-Banner mit Retry-Button. Sync-Throws im Provider-Body werden
79
- // vom useEffect-try/catch abgefangen — emitError ist nur für async.
80
- export type Subscribe<T> = (
81
- emit: (value: T) => void,
82
- emitError?: (error: Error) => void,
83
- ) => () => void;
84
-
85
- // TreeChildrenSubscribe — Lazy-Variante für dynamic Children. Wird
86
- // erst aufgerufen wenn der Knoten im UI ausgeklappt wird. Kein ctx-
87
- // Argument: Provider sind session-bound; Backend liest tenantId aus
88
- // session bei jedem fetch/dispatch. V.1.1 hatte ein ctx mit tenantId,
89
- // das aber im Browser nie echten Tenant trug (war auf SYSTEM_TENANT_ID
90
- // gepinnt) und vom einzigen V.1.2-Consumer (text-content) ignoriert
91
- // wurde. SR2-Rip 2026-05-18: Dead-API entfernt; wenn später ein
92
- // Provider tenant-aware-rendern muss (z.B. cross-tenant-Dashboards
93
- // für SystemAdmin), wird ctx mit echtem Tenant-Source aus dem Auth-
94
- // Layer re-introduziert. YAGNI bis dahin.
95
- export type TreeChildrenSubscribe = () => Subscribe<readonly TreeNode[]>;
96
-
97
- // TreeActionDef — Schema-Eintrag pro Action in der treeActions-Map
98
- // eines Features. Phase 0: Args sind ein optionales Type-Sample
99
- // (kein Validator zur Laufzeit — Validation passiert compile-time
100
- // via buildTarget-Generic, runtime via Editor-Panel-Schema).
101
- //
102
- // Lebt hier (nicht in build-target.ts) weil es konzeptuell zur
103
- // Visual-Tree-Domäne gehört, nicht zum Builder. build-target.ts
104
- // importiert den Type von hier.
105
- export type TreeActionDef<TArgs = Record<string, unknown>> = {
106
- readonly args?: TArgs;
107
- };
108
-
109
- // TreeActionsHandle<T> — Return-Type von r.treeActions(...). Trägt
110
- // den literal-typed Action-Map durch das Feature-Export-System
111
- // (siehe FeatureDefinition.exports + Memory `[EventDef-Exports-
112
- // Pattern]`). Das ist die compile-time Bridge zu buildTarget:
113
- //
114
- // const handle = r.treeActions({ edit: { args: { slug: "" as string } } });
115
- // // handle.id → TFeature (literal feature name)
116
- // // handle.treeActions → { edit: { args: { slug: string } } } (literal-typed)
117
- // buildTarget({ target: handle, action: "edit", args: { slug: "x" } });
118
- // // ^^^^^^^^^^^^^^ ^^^^^^^^
119
- // // literal-validated typed-validated
120
- //
121
- // Runtime-Lookup geht über FeatureDefinition.treeActions (erased Map),
122
- // Compile-Time-Validation über diesen Handle.
123
- export type TreeActionsHandle<
124
- TFeature extends string,
125
- TActions extends Record<string, TreeActionDef>,
126
- > = {
127
- readonly id: TFeature;
128
- readonly treeActions: TActions;
129
- };
1
+ export * from "@cosmicdrift/kumiko-types/tree-node";
@@ -32,7 +32,7 @@
32
32
 
33
33
  import type { Hono } from "hono";
34
34
  import type { AuthRoutesConfig } from "../api/auth-routes";
35
- import type { JwtHelper } from "../api/jwt";
35
+ import type { JwtHelper, JwtKeyring } from "../api/jwt";
36
36
  import type { KumikoServer, ServerOptions } from "../api/server";
37
37
  import { buildServer } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
@@ -54,7 +54,7 @@ import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
54
54
  export type BaseEntrypointOptions = {
55
55
  readonly registry: Registry;
56
56
  readonly context: AppContext;
57
- readonly jwtSecret: string;
57
+ readonly jwtSecret: string | JwtKeyring;
58
58
  readonly jwtIssuer?: string;
59
59
  readonly observability?: ObservabilityProvider;
60
60
  readonly observabilityOptions?: ObservabilityOptions;
@@ -456,6 +456,37 @@ describe("event-store: loadAllEventsByType", () => {
456
456
  expect(all.map((e) => e.version)).toEqual([1, 2, 3, 4]);
457
457
  expect(all.map((e) => (e.payload as { v: number }).v)).toEqual([0, 1, 2, 3]);
458
458
  });
459
+
460
+ test("throws once rows exceed the given rowLimit", async () => {
461
+ for (let v = 0; v < 3; v++) {
462
+ await append(testDb.db, {
463
+ aggregateId: uuid(),
464
+ aggregateType: "task",
465
+ tenantId: tenantA,
466
+ expectedVersion: 0,
467
+ type: "task.created",
468
+ payload: { v },
469
+ metadata: { userId: userA },
470
+ });
471
+ }
472
+ await expect(loadAllEventsByType(testDb.db, "task", 2)).rejects.toThrow(/exceeds 2 rows/);
473
+ });
474
+
475
+ test("does not throw when rows equal the given rowLimit", async () => {
476
+ for (let v = 0; v < 2; v++) {
477
+ await append(testDb.db, {
478
+ aggregateId: uuid(),
479
+ aggregateType: "task",
480
+ tenantId: tenantA,
481
+ expectedVersion: 0,
482
+ type: "task.created",
483
+ payload: { v },
484
+ metadata: { userId: userA },
485
+ });
486
+ }
487
+ const all = await loadAllEventsByType(testDb.db, "task", 2);
488
+ expect(all).toHaveLength(2);
489
+ });
459
490
  });
460
491
 
461
492
  describe("event-store: streamAllEventsByType (memory-bounded iteration)", () => {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Glob } from "bun";
3
+
4
+ // getUnscopedAggregateStreamMaxVersion / getUnscopedAggregateStreamTenant have
5
+ // no tenant filter — a caller can use them to probe whether a foreign tenant's
6
+ // aggregate exists (see event-store.ts SECURITY doc). Restricted to known
7
+ // seed/system-internal callers; extend only for genuine new ones.
8
+ const RESTRICTED_SYMBOLS = [
9
+ "getUnscopedAggregateStreamMaxVersion",
10
+ "getUnscopedAggregateStreamTenant",
11
+ ];
12
+
13
+ const ALLOWED_FILES = new Set([
14
+ "packages/framework/src/event-store/event-store.ts",
15
+ "packages/framework/src/event-store/index.ts",
16
+ "packages/bundled-features/src/tenant/seeding.ts",
17
+ "packages/bundled-features/src/tier-engine/feature.ts",
18
+ "packages/bundled-features/src/auth-email-password/__tests__/email-verification.integration.test.ts",
19
+ "packages/bundled-features/src/auth-email-password/__tests__/password-reset.integration.test.ts",
20
+ "packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
21
+ ]);
22
+
23
+ const REPO_ROOT = `${import.meta.dir}/../../../../..`;
24
+
25
+ describe("unscoped stream primitives — caller allowlist", () => {
26
+ test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
27
+ const glob = new Glob("packages/{framework,bundled-features}/src/**/*.ts");
28
+ const matches = new Set<string>();
29
+ for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
30
+ const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
31
+ if (RESTRICTED_SYMBOLS.some((symbol) => content.includes(symbol))) {
32
+ matches.add(relPath);
33
+ }
34
+ }
35
+
36
+ // Positive control — proves the scan actually ran and found the known
37
+ // caller, not just that it (silently) found nothing.
38
+ expect(matches.has("packages/bundled-features/src/tenant/seeding.ts")).toBe(true);
39
+
40
+ const offenders = [...matches].filter((relPath) => !ALLOWED_FILES.has(relPath));
41
+ expect(offenders).toEqual([]);
42
+ });
43
+ });
@@ -15,29 +15,9 @@ import { isStreamArchived } from "./archive";
15
15
  import { VersionConflictError } from "./errors";
16
16
  import { eventsTable } from "./events-schema";
17
17
  import { toStoredEvent } from "./row-to-stored-event";
18
+ import type { EventMetadata } from "./types";
18
19
 
19
- export type EventMetadata = {
20
- readonly userId: string;
21
- readonly requestId?: string;
22
- // End-to-end business-operation id. Root HTTP requests get it from the
23
- // x-correlation-id header (default: requestId). MSP-applies inherit it
24
- // from the triggering event. Lets you trace "which user click caused
25
- // this email 3 streams later?".
26
- readonly correlationId?: string;
27
- // Stored event id that triggered this write. Null for root commands;
28
- // set to event.id when an MSP-apply runs ctx.appendEvent. Together with
29
- // correlationId forms a causation DAG across aggregate streams.
30
- readonly causationId?: string;
31
- // Marten-conform free key/value space for app-specific metadata that
32
- // doesn't deserve its own EventMetadata field. Examples: A/B-test bucket,
33
- // feature-flag snapshot, geo-region, client SDK version. Persisted into
34
- // events.metadata jsonb (no schema change — it's already a free-form
35
- // jsonb column), survives upcasters untouched, available on every
36
- // StoredEvent.metadata.headers. Framework does not interpret values; the
37
- // app reads them when filtering/auditing. Keep values JSON-primitive
38
- // (string|number|boolean) so JSON serialization stays bulletproof.
39
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
40
- };
20
+ export type { EventMetadata } from "./types";
41
21
 
42
22
  export type EventToAppend = {
43
23
  readonly aggregateId: string;
@@ -281,9 +261,13 @@ export async function getStreamVersion(
281
261
  return selectStreamMaxVersion(db, aggregateId, tenantId);
282
262
  }
283
263
 
284
- /** MAX(version) for one aggregate — no tenant filter. Used by seed idempotency. */
264
+ /** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
265
+ * a caller can probe whether an aggregateId has any events regardless of tenant
266
+ * membership. Only call from seed/system-internal paths (idempotency checks
267
+ * against a known aggregateId) — never from a handler reachable with
268
+ * caller-controlled input. */
285
269
  // @wrapper-known semantic-alias
286
- export async function getAggregateStreamMaxVersion(
270
+ export async function getUnscopedAggregateStreamMaxVersion(
287
271
  db: DbRunner,
288
272
  aggregateId: string,
289
273
  ): Promise<number> {
@@ -291,10 +275,11 @@ export async function getAggregateStreamMaxVersion(
291
275
  }
292
276
 
293
277
  /** Stream tenant of an aggregate (the tenant_id its events live under), with no
294
- * membership/tenant filter. Recovers the write target for a systemScope
295
- * aggregate whose stream tenant isn't one of the subject's memberships.
296
- * Returns null for unknown streams. */
297
- export async function getAggregateStreamTenant(
278
+ * membership/tenant filter. SECURITY: existence-oracle, same caveat as
279
+ * getUnscopedAggregateStreamMaxVersion seed/system-internal use only. Recovers
280
+ * the write target for a systemScope aggregate whose stream tenant isn't one of
281
+ * the subject's memberships. Returns null for unknown streams. */
282
+ export async function getUnscopedAggregateStreamTenant(
298
283
  db: DbRunner,
299
284
  aggregateId: string,
300
285
  aggregateType: string,
@@ -333,15 +318,19 @@ export async function loadEventsAfterVersion(
333
318
 
334
319
  // Load every event for an aggregate_type across all tenants. Ordered by
335
320
  // (created_at, id) — chronological replay order for projection rebuilds.
336
- //
337
- // CAUTION — buffers ALL matching events in memory. Safe for smaller
338
- // aggregate-types (≤ 100k events), a memory cliff for large stores.
339
- // For >100k events use `streamAllEventsByType` (yields batchwise).
340
321
  // Mostly called from tests today — production rebuild goes through
341
322
  // projection-rebuild's own streaming path.
323
+ //
324
+ // Fails loud past LOAD_ALL_EVENTS_ROW_LIMIT rather than silently buffering
325
+ // an unbounded result set — that's the memory cliff this guard exists to
326
+ // prevent.
327
+ export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
328
+
329
+ /** @deprecated buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) instead. */
342
330
  export async function loadAllEventsByType(
343
331
  db: DbRunner,
344
332
  aggregateType: string,
333
+ rowLimit: number = LOAD_ALL_EVENTS_ROW_LIMIT,
345
334
  ): Promise<readonly StoredEvent[]> {
346
335
  const rows = await selectMany<SelectedEvent>(
347
336
  db,
@@ -352,8 +341,15 @@ export async function loadAllEventsByType(
352
341
  { col: "createdAt", direction: "asc" },
353
342
  { col: "id", direction: "asc" },
354
343
  ],
344
+ limit: rowLimit + 1,
355
345
  },
356
346
  );
347
+ if (rows.length > rowLimit) {
348
+ throw new Error(
349
+ `loadAllEventsByType("${aggregateType}") exceeds ${rowLimit} rows — ` +
350
+ "use streamAllEventsByType instead of buffering the full result set in memory.",
351
+ );
352
+ }
357
353
  return rows.map(toStoredEvent);
358
354
  }
359
355
 
@@ -15,6 +15,7 @@ import {
15
15
  import { unsafePushTables } from "../stack";
16
16
  import { createArchivedStreamsTable } from "./archive";
17
17
  import { createSnapshotsTable } from "./snapshot";
18
+ import type { EventMetadata } from "./types";
18
19
 
19
20
  // Event-store schema as a Drizzle table. The typed select/insert path handles
20
21
  // most operations; append() for subsequent versions uses raw SQL because
@@ -24,16 +25,6 @@ import { createSnapshotsTable } from "./snapshot";
24
25
  // (Redis-backed check + cached-response replay). The event-store itself
25
26
  // imposes no idempotency index — a single HTTP request may write N events
26
27
  // freely, metadata.requestId is purely a trace marker.
27
- export type EventMetadata = {
28
- readonly userId: string;
29
- readonly requestId?: string;
30
- readonly correlationId?: string;
31
- readonly causationId?: string;
32
- // App-specific free key/value (Marten "headers"). Mirror of the canonical
33
- // type in event-store.ts — kept duplicate because events-schema must stay
34
- // import-cycle-free vs the event-store module.
35
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
36
- };
37
28
 
38
29
  export const eventsTable = pgTable(
39
30
  "kumiko_events",
@@ -18,10 +18,11 @@ export {
18
18
  EVENTS_PUBSUB_CHANNEL,
19
19
  type EventMetadata,
20
20
  type EventToAppend,
21
- getAggregateStreamMaxVersion,
22
- getAggregateStreamTenant,
23
21
  getEventsHighWaterMark,
24
22
  getStreamVersion,
23
+ getUnscopedAggregateStreamMaxVersion,
24
+ getUnscopedAggregateStreamTenant,
25
+ LOAD_ALL_EVENTS_ROW_LIMIT,
25
26
  loadAggregate,
26
27
  loadAggregateAsOf,
27
28
  loadAllEventsByType,
@@ -0,0 +1,22 @@
1
+ export type EventMetadata = {
2
+ readonly userId: string;
3
+ readonly requestId?: string;
4
+ // End-to-end business-operation id. Root HTTP requests get it from the
5
+ // x-correlation-id header (default: requestId). MSP-applies inherit it
6
+ // from the triggering event. Lets you trace "which user click caused
7
+ // this email 3 streams later?".
8
+ readonly correlationId?: string;
9
+ // Stored event id that triggered this write. Null for root commands;
10
+ // set to event.id when an MSP-apply runs ctx.appendEvent. Together with
11
+ // correlationId forms a causation DAG across aggregate streams.
12
+ readonly causationId?: string;
13
+ // Marten-conform free key/value space for app-specific metadata that
14
+ // doesn't deserve its own EventMetadata field. Examples: A/B-test bucket,
15
+ // feature-flag snapshot, geo-region, client SDK version. Persisted into
16
+ // events.metadata jsonb (no schema change — it's already a free-form
17
+ // jsonb column), survives upcasters untouched, available on every
18
+ // StoredEvent.metadata.headers. Framework does not interpret values; the
19
+ // app reads them when filtering/auditing. Keep values JSON-primitive
20
+ // (string|number|boolean) so JSON serialization stays bulletproof.
21
+ readonly headers?: Readonly<Record<string, string | number | boolean>>;
22
+ };
@@ -0,0 +1,4 @@
1
+ import { describeFileProviderContract } from "../../testing/file-provider-contract";
2
+ import { createInMemoryFileProvider } from "../in-memory-provider";
3
+
4
+ describeFileProviderContract("InMemoryFileProvider", () => createInMemoryFileProvider());
@@ -10,27 +10,10 @@
10
10
  // suffix before the file extension — `foo/bar.jpg` + `"medium"` →
11
11
  // `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
12
12
 
13
+ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
13
14
  import type { FileStorageProvider } from "./types";
14
15
 
15
- export type FileHandle = {
16
- readonly key: string;
17
- read(): Promise<Uint8Array>;
18
- write(data: Uint8Array, mimeType?: string): Promise<void>;
19
- delete(): Promise<void>;
20
- exists(): Promise<boolean>;
21
- // Produce a handle for a derived key (e.g. a thumbnail). Does not touch
22
- // storage; only computes the key. Writing to the derived handle is the
23
- // caller's job.
24
- derive(suffix: string): FileHandle;
25
- };
26
-
27
- // The `ctx.files` service — a factory that materialises a FileHandle for a
28
- // storage key. One per request/event, bound to a single tenant: the provider
29
- // is resolved per-tenant through file-foundation, so uploads, ctx.files and the
30
- // GDPR jobs all hit the same store by construction.
31
- export type FileContext = {
32
- ref(key: string): FileHandle;
33
- };
16
+ export type { FileContext, FileHandle };
34
17
 
35
18
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
36
19
  // resolved (config + s3.secretAccessKey secret read) only when a handle method