@cosmicdrift/kumiko-framework 0.159.1 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +65 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  7. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  8. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  9. package/src/api/api-constants.ts +1 -0
  10. package/src/api/auth-middleware.ts +17 -44
  11. package/src/api/auth-routes.ts +6 -2
  12. package/src/api/index.ts +1 -0
  13. package/src/api/routes.ts +57 -0
  14. package/src/api/server.ts +5 -4
  15. package/src/bun-db/query.ts +12 -25
  16. package/src/crypto/kms-adapter.ts +2 -118
  17. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  18. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  19. package/src/db/cursor.ts +1 -18
  20. package/src/db/dialect.ts +8 -19
  21. package/src/db/entity-table-meta-types.ts +2 -92
  22. package/src/db/event-store-executor.ts +4 -96
  23. package/src/db/table-builder.ts +2 -19
  24. package/src/db/tenant-db.ts +6 -55
  25. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  26. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  27. package/src/engine/__tests__/engine.test.ts +28 -0
  28. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  29. package/src/engine/__tests__/registry.test.ts +40 -0
  30. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  31. package/src/engine/boot-validator/entity-handler.ts +10 -1
  32. package/src/engine/define-feature.ts +1 -0
  33. package/src/engine/define-handler.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  35. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  36. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  37. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  38. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  39. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  40. package/src/engine/feature-ast/extractors/index.ts +1 -0
  41. package/src/engine/feature-ast/index.ts +2 -0
  42. package/src/engine/feature-ast/parse.ts +3 -0
  43. package/src/engine/feature-ast/patch.ts +2 -0
  44. package/src/engine/feature-ast/patcher.ts +21 -0
  45. package/src/engine/feature-ast/patterns.ts +16 -0
  46. package/src/engine/feature-ast/render.ts +15 -0
  47. package/src/engine/feature-builder-state.ts +3 -0
  48. package/src/engine/feature-entity-handlers.ts +35 -1
  49. package/src/engine/index.ts +3 -0
  50. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  51. package/src/engine/pattern-library/library.ts +2 -0
  52. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  53. package/src/engine/registry-facade.ts +9 -0
  54. package/src/engine/registry-ingest.ts +10 -0
  55. package/src/engine/registry-state.ts +3 -0
  56. package/src/engine/types/config.ts +2 -497
  57. package/src/engine/types/define-handler.ts +2 -94
  58. package/src/engine/types/entity-handlers.ts +2 -30
  59. package/src/engine/types/feature.ts +2 -1021
  60. package/src/engine/types/fields.ts +2 -685
  61. package/src/engine/types/handlers.ts +2 -820
  62. package/src/engine/types/hooks.ts +2 -170
  63. package/src/engine/types/index.ts +44 -36
  64. package/src/engine/types/nav.ts +2 -67
  65. package/src/engine/types/ownership.ts +2 -83
  66. package/src/engine/types/projection.ts +2 -165
  67. package/src/engine/types/screen.ts +2 -747
  68. package/src/engine/types/step.ts +2 -334
  69. package/src/engine/types/workspace.ts +2 -42
  70. package/src/errors/write-error-info.ts +6 -22
  71. package/src/event-store/errors.ts +2 -35
  72. package/src/event-store/event-store.ts +2 -21
  73. package/src/event-store/snapshot.ts +11 -35
  74. package/src/event-store/types.ts +2 -22
  75. package/src/files/provider-resolver.ts +3 -5
  76. package/src/files/types.ts +5 -54
  77. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  78. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  79. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  80. package/src/pipeline/dispatch-shared.ts +39 -1
  81. package/src/pipeline/dispatch-stream.ts +74 -0
  82. package/src/pipeline/dispatcher-utils.ts +1 -1
  83. package/src/pipeline/dispatcher.ts +7 -0
  84. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  85. package/src/rate-limit/resolver.ts +10 -30
  86. package/src/secrets/envelope-cipher.ts +4 -6
  87. package/src/secrets/types.ts +2 -177
  88. package/src/stack/request-helper.ts +19 -2
  89. package/src/stack/test-stack.ts +33 -14
  90. package/src/time/tz-context.ts +9 -56
@@ -8,6 +8,7 @@ import { runBatch, unwrapSingle } from "./dispatch-batch";
8
8
  import { executeQuery } from "./dispatch-query";
9
9
  import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
10
10
  import { resolveAuthClaimsFn } from "./dispatch-shared";
11
+ import { executeStream } from "./dispatch-stream";
11
12
  import { type HandlerType, resolveType } from "./dispatcher-utils";
12
13
  import type { IdempotencyGuard } from "./idempotency";
13
14
  import type { LifecycleHooks } from "./lifecycle-pipeline";
@@ -52,6 +53,10 @@ export type Dispatcher = {
52
53
  requestId?: string,
53
54
  ): Promise<WriteResult>;
54
55
  query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
56
+ // AsyncGenerator, not Promise — gates (feature/rate-limit/access/
57
+ // validation) fire on the consumer's first `.next()` pull, not on this
58
+ // call, since they live inside the underlying async function*.
59
+ stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
55
60
  command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
56
61
  // Atomic multi-command write: all commands run in a single DB transaction.
57
62
  // On any failure, the transaction rolls back and afterCommit hooks do NOT fire.
@@ -114,6 +119,8 @@ export function createDispatcher(
114
119
 
115
120
  query: (typeOrRef, payload, user) => executeQuery(ctx, resolveType(typeOrRef), payload, user),
116
121
 
122
+ stream: (typeOrRef, payload, user) => executeStream(ctx, resolveType(typeOrRef), payload, user),
123
+
117
124
  async command(typeOrRef, payload, user) {
118
125
  const type = resolveType(typeOrRef);
119
126
  const batchResult = await runBatch(ctx, [{ type, payload }], user);
@@ -1,50 +1,12 @@
1
+ import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
1
2
  import type { DbRunner } from "../db/connection";
2
- import type {
3
- AppendEventArgs,
4
- AppendEventFn,
5
- KumikoEventTypeMap,
6
- Registry,
7
- TenantId,
8
- UnsafeAppendEventFn,
9
- } from "../engine/types";
10
- import { loadAggregate, loadAggregateAsOf, type StoredEvent } from "../event-store/event-store";
3
+ import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
4
+ import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
11
5
  import { upcastStoredEvents } from "../event-store/upcaster";
12
6
  import type { FileContext } from "../files/file-handle";
13
7
  import { appendDomainEventCore } from "./append-event-core";
14
8
 
15
- // Minimal, read+write surface handed to a MultiStreamProjection's apply()
16
- // when it needs to produce follow-up events (saga / process-manager
17
- // pattern). Keeps the MSP feature-decoupled: applies don't reach into
18
- // handler-bridge (no query/write/writeAs), they just read the aggregate
19
- // stream and append new events — Marten's session scope for projections.
20
- //
21
- // TMap propagates the strict event-type-map (see HandlerContext). Default
22
- // matches the global KumikoEventTypeMap; runtime-pluggable callers route
23
- // through unsafeAppendEvent.
24
- export type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
25
- // Append a domain event onto an aggregate stream in the CURRENT tx.
26
- // Schema-validated, archive-guarded, stream-version derived. Metadata
27
- // inherits from the triggering event (correlationId) + requestContext
28
- // (causationId is already set to the triggering event.id by the
29
- // dispatcher wrap). Strict against KumikoEventTypeMap — same contract
30
- // as HandlerContext.appendEvent (compile-time-validated payload).
31
- readonly appendEvent: AppendEventFn<TMap>;
32
- // Escape hatch for runtime-pluggable events without compile-time
33
- // augmentation. Same runtime semantics; type-surface is `payload: unknown`.
34
- readonly unsafeAppendEvent: UnsafeAppendEventFn;
35
- // Read an aggregate stream — useful when a saga needs to inspect the
36
- // current state of a different aggregate before deciding what to emit.
37
- readonly loadAggregate: (
38
- aggregateId: string,
39
- options?: { readonly asOf?: Temporal.Instant },
40
- ) => Promise<readonly StoredEvent[]>;
41
- // Binary storage handle factory, mirrors AppContext.files. Present when
42
- // the app booted with `files.storageProvider`; undefined otherwise.
43
- // Post-processing MSPs (resize, EXIF-strip, virus-scan) read bytes via
44
- // `ctx.files.ref(payload.storageKey).read()` and write derivates via
45
- // `.derive("thumb").write(...)` — binaries never ride through events.
46
- readonly files?: FileContext;
47
- };
9
+ export type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
48
10
 
49
11
  export type MultiStreamApplyContextDeps = {
50
12
  readonly registry: Registry;
@@ -1,3 +1,8 @@
1
+ import type {
2
+ RateLimitConfig,
3
+ RateLimitDecision,
4
+ RateLimitResolver,
5
+ } from "@cosmicdrift/kumiko-types/rate-limit-types";
1
6
  import type Redis from "ioredis";
2
7
  import { RateLimitError } from "../errors";
3
8
  import { RedisKeys } from "../pipeline/redis-keys";
@@ -108,36 +113,11 @@ end
108
113
  return { math.floor(tokens), retryAfterMs }
109
114
  `;
110
115
 
111
- export type RateLimitDecision = {
112
- readonly allowed: boolean;
113
- readonly limit: number;
114
- readonly remaining: number;
115
- readonly retryAfterSeconds: number;
116
- readonly windowSeconds: number;
117
- readonly resetAt: Temporal.Instant;
118
- };
119
-
120
- export type RateLimitConfig = {
121
- readonly limit: number;
122
- readonly windowSeconds: number;
123
- readonly cost?: number;
124
- };
125
-
126
- export type RateLimitResolver = {
127
- // Atomic check + deduct. Returns the decision and current bucket state
128
- // — caller decides whether to throw RateLimitError or proceed.
129
- check(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision>;
130
-
131
- // Convenience: throws RateLimitError when blocked. Useful inside the
132
- // dispatcher / middleware code-paths where the failure shape is fixed.
133
- enforce(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision>;
134
-
135
- // Read-only inspection: returns the same shape as check() but never
136
- // mutates the bucket — no token deduction, no refill-timestamp update.
137
- // Use for ops/status queries (e.g. "kumiko rl status user:42") that
138
- // must observe the bucket without disturbing it.
139
- peek(bucket: string, config: Omit<RateLimitConfig, "cost">): Promise<RateLimitDecision>;
140
- };
116
+ export type {
117
+ RateLimitConfig,
118
+ RateLimitDecision,
119
+ RateLimitResolver,
120
+ } from "@cosmicdrift/kumiko-types/rate-limit-types";
141
121
 
142
122
  export type RateLimitResolverOptions = {
143
123
  readonly redis: Redis;
@@ -3,12 +3,15 @@
3
3
  // kekVersion inside makes every value rotatable via the MasterKeyProvider
4
4
  // keyring.
5
5
 
6
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-types/envelope-cipher-types";
6
7
  import { InternalError } from "../errors/classes";
7
8
  import type { DekCache } from "./dek-cache";
8
9
  import { createDekCache, withDekCache } from "./dek-cache";
9
10
  import { decryptValue, encryptValue } from "./envelope";
10
11
  import { decodeStoredEnvelope, encodeStoredEnvelope, isStoredEnvelope } from "./stored-envelope";
11
- import type { KeyScope, MasterKeyProvider } from "./types";
12
+ import type { MasterKeyProvider } from "./types";
13
+
14
+ export type { EnvelopeCipher } from "@cosmicdrift/kumiko-types/envelope-cipher-types";
12
15
 
13
16
  export type EnvelopeCipherOptions = {
14
17
  // Shared DEK cache — pass the app-wide instance so config/entity reads
@@ -16,11 +19,6 @@ export type EnvelopeCipherOptions = {
16
19
  readonly dekCache?: DekCache;
17
20
  };
18
21
 
19
- export type EnvelopeCipher = {
20
- encrypt(plaintext: string, scope?: KeyScope): Promise<string>;
21
- decrypt(stored: string, scope?: KeyScope): Promise<string>;
22
- };
23
-
24
22
  export function createEnvelopeCipher(
25
23
  provider: MasterKeyProvider,
26
24
  opts: EnvelopeCipherOptions = {},
@@ -1,177 +1,2 @@
1
- // Envelope Encryption types. Separating DEK (per-value) from KEK (central)
2
- // is what makes key rotation cheap: on rotation we only re-wrap the small
3
- // encryptedDek, never touch the ciphertext.
4
-
5
- import type { TenantId } from "../engine";
6
-
7
- // Plaintext-secret wrapper (branded). Carries the actual string internally
8
- // but the nominal typing stops it from landing in an HTTP response by
9
- // accident — a response-serializer guard + the reveal() cost make the leak
10
- // intentional. Framework code that sees `Secret<string>` knows the caller
11
- // has already gone through the audited ctx.secrets.get path.
12
- //
13
- // The brand is a real (non-registered) Symbol so it exists at runtime for
14
- // isSecret() without clashing with user-land symbols of the same name.
15
- const SecretBrand: unique symbol = Symbol("kumiko.secret");
16
-
17
- export type Secret<T = string> = {
18
- readonly [SecretBrand]: true;
19
- readonly reveal: () => T;
20
- };
21
-
22
- // Implementation helper — bundled-features uses this to wrap a plaintext after
23
- // decryption. Kept in the framework so both sides share one canonical brand.
24
- export function createSecret<T>(value: T): Secret<T> {
25
- return {
26
- [SecretBrand]: true as const,
27
- reveal: () => value,
28
- };
29
- }
30
-
31
- // True for any object carrying the Secret brand. Used by the response guard
32
- // to reject leaks before serialization.
33
- export function isSecret(v: unknown): v is Secret<unknown> {
34
- return typeof v === "object" && v !== null && SecretBrand in v;
35
- }
36
-
37
- // --- Compile-time response guard (R6) --------------------------------------
38
- //
39
- // ContainsSecret<T> is `true` only when a Secret<> is DEFINITELY present
40
- // somewhere in T. The handler-registration guard (defineWriteHandler/
41
- // defineQueryHandler) turns a `true` into a compile error — the static twin of
42
- // assertNoSecretLeak's runtime walk.
43
- //
44
- // Biased to `false`: anything it cannot inspect — a bare generic type param (a
45
- // handler generic over its response), `unknown`/`any`, `never` — resolves to
46
- // `false` = allowed, with the runtime guard as the backstop. The alternative
47
- // (default-to-leak) false-flags every legitimate generic-over-response handler.
48
- //
49
- // Branch order is load-bearing: never/unknown/any first (uninspectable), then
50
- // Secret, then primitives (covers branded primitives like TenantId without
51
- // enumerating them), then the SafeLeaf allowlist (opaque class instances that
52
- // blind `{ [K in keyof T] }` recursion would mangle — the type-level mirror of
53
- // leak-guard.ts skipping non-plain objects), then arrays, then a "does any
54
- // field contain a secret" fold over plain objects.
55
- type Primitive = string | number | boolean | bigint | symbol | null | undefined;
56
-
57
- // Opaque built-in leaves a response legitimately carries; never recurse into
58
- // them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
59
- // Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
60
- // already fell through to `false` via the object-mapped-type branch — listed
61
- // explicitly here so the compile-time treatment matches leak-guard.ts's
62
- // runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
63
- // instead of looking like an oversight.
64
- type SafeLeaf =
65
- | Date
66
- | RegExp
67
- | Temporal.Instant
68
- | Temporal.ZonedDateTime
69
- | Temporal.PlainDate
70
- | Temporal.PlainDateTime
71
- | Temporal.PlainTime
72
- | Temporal.PlainYearMonth
73
- | Temporal.PlainMonthDay
74
- | Temporal.Duration
75
- | Map<unknown, unknown>
76
- | Set<unknown>;
77
-
78
- export type ContainsSecret<T> = [T] extends [never]
79
- ? false
80
- : unknown extends T
81
- ? false
82
- : T extends Secret<unknown>
83
- ? true
84
- : T extends Primitive
85
- ? false
86
- : T extends SafeLeaf
87
- ? false
88
- : T extends readonly (infer U)[]
89
- ? ContainsSecret<U>
90
- : T extends object
91
- ? true extends { [K in keyof T]-?: ContainsSecret<T[K]> }[keyof T]
92
- ? true
93
- : false
94
- : false;
95
-
96
- // Per-read audit context. Populated by requireSecretsContext() wrapper so
97
- // handlers don't need to pass userId/handlerName manually on every call.
98
- // Undefined for framework-internal reads (rotation job, tests) — the audit
99
- // table stays a "who touched this credential" log, not a crash-report sink.
100
- export type SecretAuditContext = {
101
- readonly userId: string;
102
- readonly handlerName: string;
103
- };
104
-
105
- // Feature code can pass either the raw qualified-name string or a typed
106
- // handle returned by r.secret. The handle form is safer — renaming the
107
- // r.secret call updates all references through the import graph.
108
- export type SecretKeyRef = string | { readonly name: string };
109
-
110
- // The ctx.secrets contract. Concrete implementation lives in bundled-features
111
- // (createSecretsContext) where the DB and MasterKeyProvider are known. This
112
- // lean interface is what the framework's HandlerContext carries so engine
113
- // code can talk about it without pulling in bundled-features.
114
- export interface SecretsContext {
115
- get(
116
- tenantId: TenantId,
117
- key: SecretKeyRef,
118
- auditCtx?: SecretAuditContext,
119
- ): Promise<Secret<string> | undefined>;
120
- // Metadata-only existence probe: no decryption, no read-audit event.
121
- // For readiness checks — use get() when the value itself is needed.
122
- has(tenantId: TenantId, key: SecretKeyRef): Promise<boolean>;
123
- set(
124
- tenantId: TenantId,
125
- key: SecretKeyRef,
126
- value: string,
127
- opts?: { redact?: (plaintext: string) => string; hint?: string; updatedBy?: string },
128
- ): Promise<void>;
129
- delete(tenantId: TenantId, key: SecretKeyRef, opts?: { deletedBy?: string }): Promise<boolean>;
130
- }
131
-
132
- export type Envelope = {
133
- // AES-256-GCM ciphertext of the plaintext, keyed with a DEK.
134
- readonly ciphertext: Buffer;
135
- // GCM nonce (12 bytes). Generated fresh per encryption.
136
- readonly iv: Buffer;
137
- // GCM auth tag (16 bytes). Guarantees the ciphertext wasn't tampered.
138
- readonly authTag: Buffer;
139
- // DEK wrapped with the current KEK. Decryption needs provider.unwrapDek
140
- // with the kekVersion to recover the DEK.
141
- readonly encryptedDek: Buffer;
142
- // Which KEK version was used to wrap the DEK. On rotation, rows with old
143
- // versions still decrypt — the provider keeps a keyring of historical KEKs.
144
- readonly kekVersion: number;
145
- };
146
-
147
- // BYOK hook: callers pass the tenant a value belongs to; a per-tenant-KMS
148
- // provider keys its wrap/unwrap on it. EnvMasterKeyProvider (app-wide
149
- // keyring) ignores it — the param exists so the contract doesn't have to
150
- // break when a tenant-scoped provider ships.
151
- export type KeyScope = {
152
- readonly tenantId?: TenantId;
153
- };
154
-
155
- // The contract a KEK backend must fulfil. The framework sees only this
156
- // interface; concrete implementations live in separate packages
157
- // (@cosmicdrift/kumiko-secrets-vault, @cosmicdrift/kumiko-secrets-aws-kms, ...). The default is
158
- // EnvMasterKeyProvider which reads keys from environment variables.
159
- export interface MasterKeyProvider {
160
- // Wrap a fresh DEK with the current KEK. Returns the wrapped bytes + the
161
- // KEK version used — the version ends up in the Envelope so decryption
162
- // later knows which KEK to ask for.
163
- wrapDek(dek: Buffer, scope?: KeyScope): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
164
-
165
- // Unwrap a previously-wrapped DEK. During rotation the provider must
166
- // accept older kekVersion values (2-version window minimum), otherwise
167
- // old rows become unreadable.
168
- unwrapDek(encryptedDek: Buffer, kekVersion: number, scope?: KeyScope): Promise<Buffer>;
169
-
170
- // Which KEK version new wraps use. Rotation flips this to a new value
171
- // and older-version reads continue to work until rows are re-wrapped.
172
- currentVersion(): number;
173
-
174
- // Health check: can the provider talk to its backend? Used by
175
- // /health/ready. Cheap probe, no KEK material read.
176
- isAvailable(): Promise<boolean>;
177
- }
1
+ // Legacy path re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/secrets-types";
@@ -1,4 +1,5 @@
1
1
  import type { Hono } from "hono";
2
+ import type { SessionCreator } from "../api/auth-routes";
2
3
  import type { JwtHelper } from "../api/jwt";
3
4
  import type { SessionUser } from "../engine/types";
4
5
 
@@ -98,9 +99,25 @@ export type RequestHelper = {
98
99
  ) => Promise<Response>;
99
100
  };
100
101
 
101
- export function createRequestHelper(app: Hono, jwt: JwtHelper): RequestHelper {
102
+ export type RequestHelperOptions = {
103
+ // When sessionChecker is wired (sessions feature), JWTs without jti are
104
+ // rejected as no_sid. Seed/test helpers that only jwt.sign(user) need a
105
+ // live sid — create one via the same sessionCreator login uses (#1372).
106
+ readonly sessionCreator?: SessionCreator;
107
+ };
108
+
109
+ export function createRequestHelper(
110
+ app: Hono,
111
+ jwt: JwtHelper,
112
+ options: RequestHelperOptions = {},
113
+ ): RequestHelper {
102
114
  async function authHeader(user: SessionUser): Promise<Record<string, string>> {
103
- const token = await jwt.sign(user);
115
+ let forJwt = user;
116
+ if (options.sessionCreator && !user.sid) {
117
+ const sid = await options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
118
+ forJwt = { ...user, sid };
119
+ }
120
+ const token = await jwt.sign(forJwt);
104
121
  return { Authorization: `Bearer ${token}` };
105
122
  }
106
123
 
@@ -124,6 +124,16 @@ export type TestStackOptions = {
124
124
  sseBroker: import("../api/sse-broker").SseBroker;
125
125
  redis: import("ioredis").default;
126
126
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
127
+ /** Optional post-factory enricher (e.g. merge auth-foundation tenant
128
+ * providers). Keeps framework free of a bundled-features dependency. */
129
+ enrichAnonymousAccess?: (
130
+ base: import("../api/server").ServerOptions["anonymousAccess"] | undefined,
131
+ deps: {
132
+ registry: Registry;
133
+ // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
134
+ db: any;
135
+ },
136
+ ) => Promise<import("../api/server").ServerOptions["anonymousAccess"] | undefined>;
127
137
  /** Opt-in JobRunner wired into ctx.jobRunner and merged into
128
138
  * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
129
139
  * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
@@ -382,19 +392,24 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
382
392
  : {}),
383
393
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
384
394
  ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
385
- ...(options.anonymousAccess
386
- ? {
387
- anonymousAccess:
388
- typeof options.anonymousAccess === "function"
389
- ? options.anonymousAccess({
390
- registry,
391
- db: testDb.db,
392
- sseBroker,
393
- redis: testRedis.redis,
394
- })
395
- : options.anonymousAccess,
396
- }
397
- : {}),
395
+ ...(await (async () => {
396
+ const baseAnon =
397
+ typeof options.anonymousAccess === "function"
398
+ ? options.anonymousAccess({
399
+ registry,
400
+ db: testDb.db,
401
+ sseBroker,
402
+ redis: testRedis.redis,
403
+ })
404
+ : options.anonymousAccess;
405
+ const resolvedAnon = options.enrichAnonymousAccess
406
+ ? await options.enrichAnonymousAccess(baseAnon, {
407
+ registry,
408
+ db: testDb.db,
409
+ })
410
+ : baseAnon;
411
+ return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
412
+ })()),
398
413
  });
399
414
 
400
415
  const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
@@ -406,7 +421,11 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
406
421
  // timer loop call start() again (idempotent) after setup.
407
422
  if (eventDispatcher) await eventDispatcher.ensureRegistered();
408
423
 
409
- const http = createRequestHelper(server.app, server.jwt);
424
+ const http = createRequestHelper(server.app, server.jwt, {
425
+ ...(options.authConfig?.sessionCreator !== undefined && {
426
+ sessionCreator: options.authConfig.sessionCreator,
427
+ }),
428
+ });
410
429
 
411
430
  return {
412
431
  app: server.app,
@@ -13,65 +13,18 @@
13
13
  // `tenant` + `user` sind die TZ-Defaults für den aktuellen Request. Aktueller
14
14
  // Stand: beide default auf "UTC" — sobald tenant.timezone +
15
15
  // user.timezone Felder existieren, lese ich sie aus dem Request-Context.
16
+ //
17
+ // Die reinen Type-Contracts (TzContext, TzContextOptions, LocatedTimestampJson)
18
+ // leben in @cosmicdrift/kumiko-types/tz-context — hier nur die Factories.
16
19
 
17
- import type { GeoAddress, GeoCoordinates, GeoTzProvider } from "./geo-tz";
20
+ import type { TzContext, TzContextOptions } from "@cosmicdrift/kumiko-types/tz-context";
18
21
  import { ensureTemporalPolyfill, getTemporal } from "./polyfill";
19
22
 
20
- // JSON-Form für Wall-Clock+TZ — siehe createLocatedTimestampField() in
21
- // engine/factories.ts. Zwei Felder, idiotensicher.
22
- export type LocatedTimestampJson = {
23
- /** Wall-Clock-ISO ohne Offset, z.B. "2026-04-03T10:00:00" */
24
- readonly at: string;
25
- /** IANA-Zone, z.B. "Europe/Lisbon" */
26
- readonly tz: string;
27
- };
28
-
29
- export type TzContext = {
30
- /** Default-TZ des Mandanten (aus tenant.timezone, default "UTC"). */
31
- readonly tenant: string;
32
- /** Anzeige-TZ des aktuellen Users (User-Profil-Override, fallback Tenant). */
33
- readonly user: string;
34
-
35
- /** Aktueller Moment als UTC-Instant. */
36
- now(): Temporal.Instant;
37
- /** Aktueller Moment als ZonedDateTime in der gewünschten Zone. */
38
- nowIn(tz: string): Temporal.ZonedDateTime;
39
-
40
- /** Heutiges Kalender-Datum in der gewünschten Zone. */
41
- today(tz: string): Temporal.PlainDate;
42
- /** Tagesgrenzen (00:00 bis 24:00 nächster Tag) als UTC-Instants — für DB-Range-Queries. */
43
- todayRange(tz: string): { readonly start: Temporal.Instant; readonly end: Temporal.Instant };
44
-
45
- /** Wall-Clock-String + IANA-Zone → ZonedDateTime. */
46
- parse(wallClock: string, tz: string): Temporal.ZonedDateTime;
47
-
48
- /** ZonedDateTime → UTC-Instant. */
49
- toInstant(zdt: Temporal.ZonedDateTime): Temporal.Instant;
50
-
51
- /** ZonedDateTime → JSON-Pair { at, tz } (API-Boundary). */
52
- toLocatedJson(zdt: Temporal.ZonedDateTime): LocatedTimestampJson;
53
-
54
- /** JSON-Pair { at, tz } → ZonedDateTime (Wall-Clock + IANA). */
55
- fromLocatedJson(obj: LocatedTimestampJson): Temporal.ZonedDateTime;
56
-
57
- /** Geo-Koordinaten → IANA-Zone via konfiguriertem GeoTzProvider.
58
- * Wirft wenn kein Provider konfiguriert ist (v1-Default). */
59
- fromCoordinates(coords: GeoCoordinates): Promise<string>;
60
- /** Postadresse → IANA-Zone via GeoTzProvider. Wirft wenn kein Provider
61
- * konfiguriert ist ODER der Provider kein fromAddress unterstützt (der
62
- * Offline-lat/lng-Provider tut das nicht). */
63
- fromAddress(address: GeoAddress): Promise<string>;
64
- };
65
-
66
- export type TzContextOptions = {
67
- /** Tenant-Default-TZ. Default "UTC" wenn nicht gesetzt. */
68
- readonly tenant?: string;
69
- /** User-Override. Default = tenant. */
70
- readonly user?: string;
71
- /** Optionaler Geo→Zone-Adapter für ctx.tz.fromCoordinates / fromAddress.
72
- * Ohne Provider werfen diese Methoden. */
73
- readonly geoTz?: GeoTzProvider;
74
- };
23
+ export type {
24
+ LocatedTimestampJson,
25
+ TzContext,
26
+ TzContextOptions,
27
+ } from "@cosmicdrift/kumiko-types/tz-context";
75
28
 
76
29
  /**
77
30
  * Factory: erzeugt einen TzContext für den aktuellen Request.