@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.
- package/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +4 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +83 -17
- package/src/api/index.ts +8 -4
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +34 -2
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +92 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +10 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +2 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/define-handler.ts +94 -0
- package/src/engine/types/entity-handlers.ts +30 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +45 -0
- package/src/engine/types/fields.ts +19 -31
- package/src/engine/types/handlers.ts +7 -26
- package/src/engine/types/hooks.ts +1 -15
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +34 -9
- package/src/engine/types/ownership.ts +83 -0
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +0 -46
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/entrypoint/index.ts +2 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/event-store.ts +28 -32
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/types.ts +22 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/dispatch-shared.ts +12 -2
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +13 -39
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
|
|
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",
|
package/src/event-store/index.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/files/file-handle.ts
CHANGED
|
@@ -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
|