@cosmicdrift/kumiko-framework 0.340.0 → 0.341.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -1
- package/dist/api/route-registrars.d.ts +4 -2
- package/dist/api/route-registrars.js +9 -1
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +43 -5
- package/dist/db/queries/shadow-swap.d.ts +2 -1
- package/dist/db/queries/shadow-swap.js +70 -7
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +48 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/handlers.js +11 -1
- package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/hooks.js +14 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
- package/dist/engine/feature-ast/patterns.d.ts +2 -1
- package/dist/engine/feature-ast/render.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -1
- package/dist/engine/feature-entity-handlers.js +4 -0
- package/dist/engine/feature-ui-extensions.js +9 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/membership-roles.js +5 -1
- package/dist/engine/pattern-library/mixed-schemas.js +5 -0
- package/dist/engine/registry-state.js +38 -5
- package/dist/engine/registry-validate.js +2 -0
- package/dist/engine/tier-resolver-extension.d.ts +2 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/entrypoint/index.js +25 -6
- package/dist/errors/classes.d.ts +5 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/file-routes.js +7 -2
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/provider-resolver.d.ts +2 -0
- package/dist/files/provider-resolver.js +16 -1
- package/dist/files/resolve-content-type.d.ts +11 -0
- package/dist/files/resolve-content-type.js +71 -0
- package/dist/files/types.d.ts +1 -1
- package/dist/files/types.js +5 -1
- package/dist/jobs/job-runner.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +4 -1
- package/dist/pipeline/dispatch-shared.js +70 -9
- package/dist/pipeline/dispatch-write.js +35 -10
- package/dist/pipeline/dispatcher.d.ts +2 -0
- package/dist/pipeline/dispatcher.js +17 -1
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/rate-limit/bucket.d.ts +4 -0
- package/dist/rate-limit/bucket.js +25 -0
- package/dist/rate-limit/index.d.ts +2 -2
- package/dist/rate-limit/index.js +2 -2
- package/dist/rate-limit/middleware.d.ts +9 -0
- package/dist/rate-limit/middleware.js +33 -2
- package/dist/rate-limit/resolver.js +2 -1
- package/dist/redis/cache-sync-bus.d.ts +24 -0
- package/dist/redis/cache-sync-bus.js +152 -0
- package/dist/redis/cache-sync-topics.d.ts +17 -0
- package/dist/redis/cache-sync-topics.js +22 -0
- package/dist/redis/index.d.ts +4 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +28 -0
- package/dist/stack/redis.d.ts +6 -1
- package/dist/stack/redis.js +7 -2
- package/dist/stack/test-stack.d.ts +15 -0
- package/dist/stack/test-stack.js +21 -1
- package/package.json +4 -4
- package/src/changes.json +47 -0
|
@@ -5,7 +5,7 @@ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db
|
|
|
5
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
6
|
import { createSystemUser } from "../engine/system-user.js";
|
|
7
7
|
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
8
|
-
import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
|
|
8
|
+
import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
|
|
9
9
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
10
10
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
11
11
|
import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
|
|
@@ -14,8 +14,9 @@ import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.j
|
|
|
14
14
|
import { createFileContext } from "../files/file-handle.js";
|
|
15
15
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
16
16
|
import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
17
|
-
import { buildBucketKey } from "../rate-limit/index.js";
|
|
17
|
+
import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
|
|
18
18
|
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
19
|
+
import { isPlainObject } from "../utils/is-plain-object.js";
|
|
19
20
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
20
21
|
import { appendDomainEventCore } from "./append-event-core.js";
|
|
21
22
|
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver.js";
|
|
@@ -92,6 +93,14 @@ function createSystemScopedDbGuard(handlerType, fieldName, hint) {
|
|
|
92
93
|
export function isMemberResolutionPrincipal(user) {
|
|
93
94
|
return user.origin === "member-resolution";
|
|
94
95
|
}
|
|
96
|
+
// DispatchContext.jobRunner is typed as the handleEvent-only JobRunnerRef but
|
|
97
|
+
// holds the concrete runner at boot; narrow instead of casting.
|
|
98
|
+
function isNotifyJobDispatcher(runner) {
|
|
99
|
+
return (typeof runner === "object" &&
|
|
100
|
+
runner !== null &&
|
|
101
|
+
"dispatch" in runner &&
|
|
102
|
+
typeof runner.dispatch === "function");
|
|
103
|
+
}
|
|
95
104
|
async function denyMemberResolutionWrite() {
|
|
96
105
|
throw memberResolutionReadOnlyDenied();
|
|
97
106
|
}
|
|
@@ -206,7 +215,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
206
215
|
userId: user.id,
|
|
207
216
|
...(reqCtx && { requestId: reqCtx.requestId }),
|
|
208
217
|
});
|
|
209
|
-
const notify = context._notifyFactory
|
|
218
|
+
const notify = context._notifyFactory
|
|
219
|
+
? context._notifyFactory(user, user.tenantId, isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined)
|
|
220
|
+
: undefined;
|
|
210
221
|
// Mirror notify: only built when the config feature wired its factory.
|
|
211
222
|
const config = context._configAccessorFactory && db
|
|
212
223
|
? context._configAccessorFactory({
|
|
@@ -604,8 +615,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
604
615
|
// Request → Session → Boot-Default chain above.
|
|
605
616
|
const safeUserLocale = user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
|
|
606
617
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
618
|
+
// The payload-bucket digest is dispatcher-internal; handlers must not be
|
|
619
|
+
// able to compute bucket keys.
|
|
620
|
+
const { _rateLimitPayloadDigest: _omitPayloadDigest, ...contextForHandler } = context;
|
|
607
621
|
const handlerContext = {
|
|
608
|
-
...
|
|
622
|
+
...contextForHandler,
|
|
609
623
|
registry,
|
|
610
624
|
db: exposedDb,
|
|
611
625
|
dbOutsideTransaction,
|
|
@@ -636,6 +650,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
636
650
|
// Propagate the feature-toggle resolver so the lifecycle pipeline,
|
|
637
651
|
// MSP runner, and ctx.hasFeature all pull from the same source.
|
|
638
652
|
...(effectiveFeatures && { effectiveFeatures }),
|
|
653
|
+
...(ctx.cacheSync && { cacheSync: ctx.cacheSync }),
|
|
639
654
|
// Lets write handlers call ctx.jobRunner.dispatch(...) directly, same
|
|
640
655
|
// as a follow-up job would (test-stack.ts wires the matching runner).
|
|
641
656
|
...(jobRunner && { jobRunner }),
|
|
@@ -894,11 +909,57 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
|
|
|
894
909
|
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
895
910
|
});
|
|
896
911
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
912
|
+
try {
|
|
913
|
+
await context.rateLimit.enforce(bucket.key, {
|
|
914
|
+
limit: effective.limit,
|
|
915
|
+
windowSeconds: effective.windowSeconds,
|
|
916
|
+
cost: effective.cost,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
// Backend failure (Redis down): fail closed as 503, same as L1/L2.
|
|
921
|
+
if (error instanceof RateLimitError)
|
|
922
|
+
throw error;
|
|
923
|
+
throw new RateLimitUnavailableError({
|
|
924
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Payload-field rate limits (additionalRateLimits). Runs after schema
|
|
929
|
+
// validation so the field is typed, regardless of what the handler later
|
|
930
|
+
// returns — hit and non-hit callers see the same limit behavior. SYSTEM
|
|
931
|
+
// identities (jobs, internal dispatches) are not the anonymous traffic this
|
|
932
|
+
// protects against.
|
|
933
|
+
export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, data) {
|
|
934
|
+
// skip: no payload buckets declared, or a SYSTEM caller
|
|
935
|
+
if (!limits || isSystemIdentity(user))
|
|
936
|
+
return;
|
|
937
|
+
const { appContext: context } = ctx;
|
|
938
|
+
const digest = context._rateLimitPayloadDigest;
|
|
939
|
+
if (!context.rateLimit || !digest) {
|
|
940
|
+
throw new InternalError({
|
|
941
|
+
message: `Handler "${handlerName}" declares additionalRateLimits but no RateLimitResolver/payload digest is configured.`,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
// Boot validation guarantees an object schema; anything else must not pass unlimited.
|
|
945
|
+
if (!isPlainObject(data)) {
|
|
946
|
+
throw new InternalError({
|
|
947
|
+
message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
for (const { per, limit, windowSeconds, cost } of limits) {
|
|
951
|
+
const value = data[per.payloadField];
|
|
952
|
+
// skip: optional field not sent — nothing to bucket on
|
|
953
|
+
if (value === undefined)
|
|
954
|
+
continue;
|
|
955
|
+
if (typeof value !== "string") {
|
|
956
|
+
throw new InternalError({
|
|
957
|
+
message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
const key = buildPayloadBucketKey(handlerName, per.payloadField, digest(normalizePayloadBucketValue(value)));
|
|
961
|
+
await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
|
|
962
|
+
}
|
|
902
963
|
}
|
|
903
964
|
// Build the per-hook context every auth-claims invocation gets. Claims
|
|
904
965
|
// hooks run OUTSIDE any request transaction (login is itself the root
|
|
@@ -10,8 +10,9 @@ import { defineTransitions, guardTransition } from "../engine/state-machine.js";
|
|
|
10
10
|
import { HookPhases } from "../engine/types/index.js";
|
|
11
11
|
import { runValidation } from "../engine/validation.js";
|
|
12
12
|
import { AccessDeniedError, FrameworkReasons, InternalError, isKumikoError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
|
|
13
|
+
import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
|
|
13
14
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
14
|
-
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, checkFeatureEnabled, enforceRateLimit, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
15
|
+
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, checkFeatureEnabled, enforcePayloadRateLimits, enforceRateLimit, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
15
16
|
import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
16
17
|
import { handlerAccessError } from "./handler-access-error.js";
|
|
17
18
|
import { runProjections } from "./projections-runner.js";
|
|
@@ -44,12 +45,11 @@ function getTransitions(ctx, args) {
|
|
|
44
45
|
}
|
|
45
46
|
// A successful config:write:set/reset returns { key, scope, ... } — see
|
|
46
47
|
// bundled-features/src/config/handlers/{set,reset}.write.ts. Narrows the
|
|
47
|
-
// otherwise-`unknown` WriteResult.data
|
|
48
|
-
|
|
49
|
-
function isConfigWriteResultForKey(data, key) {
|
|
48
|
+
// otherwise-`unknown` WriteResult.data without an unchecked cast.
|
|
49
|
+
function isConfigWriteResult(data) {
|
|
50
50
|
if (typeof data !== "object" || data === null || !("key" in data))
|
|
51
51
|
return false;
|
|
52
|
-
return data.key ===
|
|
52
|
+
return typeof data.key === "string";
|
|
53
53
|
}
|
|
54
54
|
// fw#2462: dispatch-shared.ts caches the resolved tenant:config:timezone
|
|
55
55
|
// value per tenant. A successful write to that key must drop the stale
|
|
@@ -64,14 +64,30 @@ function invalidateTenantTimezoneCache(ctx, type, user, result) {
|
|
|
64
64
|
if (type !== CONFIG_WRITE_SET_TYPE && type !== CONFIG_WRITE_RESET_TYPE)
|
|
65
65
|
return;
|
|
66
66
|
// skip: write was for a different config key
|
|
67
|
-
if (!
|
|
67
|
+
if (!isConfigWriteResult(result.data) || result.data.key !== TENANT_TIMEZONE_CONFIG_KEY)
|
|
68
68
|
return;
|
|
69
|
-
if (result.data.scope === ConfigScopes.system)
|
|
69
|
+
if (result.data.scope === ConfigScopes.system)
|
|
70
70
|
ctx.tenantTimezoneCache.clear();
|
|
71
|
-
|
|
72
|
-
else {
|
|
71
|
+
else
|
|
73
72
|
ctx.tenantTimezoneCache.invalidate(user.tenantId);
|
|
74
|
-
|
|
73
|
+
}
|
|
74
|
+
// Every committed config write tells the other processes (config-derived
|
|
75
|
+
// caches such as the tenant timezone and the file provider live per process).
|
|
76
|
+
// Only after commit: a pod receiving this earlier would re-read the pre-write
|
|
77
|
+
// row and cache the stale value again.
|
|
78
|
+
function publishConfigWrite(ctx, type, user, result) {
|
|
79
|
+
// skip: failed write, nothing changed
|
|
80
|
+
if (!result.isSuccess)
|
|
81
|
+
return;
|
|
82
|
+
// skip: not a config:write:set/reset dispatch
|
|
83
|
+
if (type !== CONFIG_WRITE_SET_TYPE && type !== CONFIG_WRITE_RESET_TYPE)
|
|
84
|
+
return;
|
|
85
|
+
// skip: result carries no config key to announce
|
|
86
|
+
if (!isConfigWriteResult(result.data))
|
|
87
|
+
return;
|
|
88
|
+
const { key, scope } = result.data;
|
|
89
|
+
const message = scope === ConfigScopes.system ? { scope: "all", key } : { tenantId: user.tenantId, key };
|
|
90
|
+
ctx.cacheSync?.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
|
|
75
91
|
}
|
|
76
92
|
// Runs lifecycle hooks for a handler result. inTransaction hooks fire NOW
|
|
77
93
|
// (they see the tx via ctx.db when batch/write opens a transaction).
|
|
@@ -329,6 +345,14 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
329
345
|
if (!parsed.success) {
|
|
330
346
|
return writeFailure(validationErrorFromZod(parsed.error));
|
|
331
347
|
}
|
|
348
|
+
try {
|
|
349
|
+
await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, parsed.data);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
if (isKumikoError(e))
|
|
353
|
+
return writeFailure(e);
|
|
354
|
+
throw e;
|
|
355
|
+
}
|
|
332
356
|
const hookErrors = runValidation(registry, type, parsed.data); // @cast-boundary engine-payload
|
|
333
357
|
if (hookErrors) {
|
|
334
358
|
return writeFailure(new ValidationError({
|
|
@@ -459,6 +483,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
459
483
|
// repopulate the cache with the stale value for a full TTL.
|
|
460
484
|
afterCommitHooks.push(async () => {
|
|
461
485
|
invalidateTenantTimezoneCache(ctx, type, user, result);
|
|
486
|
+
publishConfigWrite(ctx, type, user, result);
|
|
462
487
|
});
|
|
463
488
|
}
|
|
464
489
|
// Response-guard: block Secret<> leaks in write responses (SaveContext
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
1
2
|
import type { SseBroker } from "../api/sse-broker.js";
|
|
2
3
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
|
|
3
4
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
@@ -15,6 +16,7 @@ export type DispatcherOptions = {
|
|
|
15
16
|
membershipQuery?: string;
|
|
16
17
|
effectiveFeatures?: EffectiveFeaturesResolver;
|
|
17
18
|
sseBroker?: SseBroker;
|
|
19
|
+
cacheSync?: CacheSyncBus | null;
|
|
18
20
|
};
|
|
19
21
|
export type Dispatcher = {
|
|
20
22
|
write(type: HandlerType, payload: unknown, user: SessionUser, requestId?: string): Promise<WriteResult>;
|
|
@@ -3,10 +3,11 @@ import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from
|
|
|
3
3
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
4
4
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
5
5
|
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
6
|
+
import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
|
|
6
7
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
7
8
|
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
8
9
|
import { executeQuery } from "./dispatch-query.js";
|
|
9
|
-
import { resolveAuthClaimsFn } from "./dispatch-shared.js";
|
|
10
|
+
import { resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY } from "./dispatch-shared.js";
|
|
10
11
|
import { executeStream } from "./dispatch-stream.js";
|
|
11
12
|
import { resolveType } from "./dispatcher-utils.js";
|
|
12
13
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
@@ -54,6 +55,20 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
54
55
|
// One per dispatcher instance (not a module-level singleton) so caches
|
|
55
56
|
// never leak across separately-booted apps or test stacks.
|
|
56
57
|
const tenantTimezoneCache = createTenantTimezoneCache();
|
|
58
|
+
const cacheSync = options.cacheSync ?? undefined;
|
|
59
|
+
cacheSync?.subscribe(CACHE_SYNC_TOPICS.tenantConfig, (message) => {
|
|
60
|
+
// skip: malformed message from another process
|
|
61
|
+
if (!isTenantConfigSyncMessage(message))
|
|
62
|
+
return;
|
|
63
|
+
// skip: a different config key, the timezone cache is unaffected
|
|
64
|
+
if (message.key !== undefined && message.key !== TENANT_TIMEZONE_CONFIG_KEY)
|
|
65
|
+
return;
|
|
66
|
+
if ("scope" in message)
|
|
67
|
+
tenantTimezoneCache.clear();
|
|
68
|
+
else
|
|
69
|
+
tenantTimezoneCache.invalidate(message.tenantId);
|
|
70
|
+
});
|
|
71
|
+
cacheSync?.onResync(() => tenantTimezoneCache.clear());
|
|
57
72
|
const escapeHatchReportWindow = createEscapeHatchReportWindow();
|
|
58
73
|
const dispatcherTracer = context.tracer ?? getFallbackTracer();
|
|
59
74
|
const dispatcherMeter = context.meter ?? getFallbackMeter();
|
|
@@ -82,6 +97,7 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
82
97
|
tableCache,
|
|
83
98
|
transitionCache,
|
|
84
99
|
tenantTimezoneCache,
|
|
100
|
+
cacheSync,
|
|
85
101
|
escapeHatchReportWindow,
|
|
86
102
|
tracer: dispatcherTracer,
|
|
87
103
|
meter: dispatcherMeter,
|
|
@@ -186,7 +186,7 @@ export async function rebuildProjection(projectionName, deps) {
|
|
|
186
186
|
// reconstruct (#498 ghost — direct-inserted without a .created event),
|
|
187
187
|
// which the swap would silently drop. Implicit projections only.
|
|
188
188
|
if (projection.isImplicit === true) {
|
|
189
|
-
await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList);
|
|
189
|
+
await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList, projection.extraSourceRowIds);
|
|
190
190
|
// Non-blocking counterpart: reports column-level drift (bidx excluded)
|
|
191
191
|
// without aborting — see countColumnDrift for why this isn't fail-hard.
|
|
192
192
|
const drift = await countColumnDrift(tx, meta.tableName, meta);
|
|
@@ -12,3 +12,7 @@ export type BucketResult = {
|
|
|
12
12
|
readonly reason: string;
|
|
13
13
|
};
|
|
14
14
|
export declare function buildBucketKey(option: RateLimitOption, ctx: BucketContext): BucketResult;
|
|
15
|
+
export declare function normalizePayloadBucketValue(value: string): string;
|
|
16
|
+
export declare function buildPayloadBucketKey(handlerName: string, field: string, digest: string): string;
|
|
17
|
+
export declare function toPublicBucketName(bucket: string): string;
|
|
18
|
+
export declare function createPayloadDigest(masterSecret: string): (value: string) => string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { derivePurposeSecret } from "../secrets/derive-purpose-secret.js";
|
|
1
3
|
export function buildBucketKey(option, ctx) {
|
|
2
4
|
switch (option.per) {
|
|
3
5
|
case "user":
|
|
@@ -18,3 +20,26 @@ export function buildBucketKey(option, ctx) {
|
|
|
18
20
|
return { kind: "key", key: `ip+handler:${ctx.ip}:${ctx.handlerName}` };
|
|
19
21
|
}
|
|
20
22
|
}
|
|
23
|
+
// Payload-field buckets: the value (e.g. a recipient address) is normalized
|
|
24
|
+
// and HMAC-hashed so Redis never holds it in plaintext.
|
|
25
|
+
const PAYLOAD_BUCKET_PURPOSE = "kumiko:rate-limit:payload-bucket";
|
|
26
|
+
export function normalizePayloadBucketValue(value) {
|
|
27
|
+
return value.trim().toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
export function buildPayloadBucketKey(handlerName, field, digest) {
|
|
30
|
+
return `payload+handler:${handlerName}:${field}:${digest}`;
|
|
31
|
+
}
|
|
32
|
+
// Everything after the scope tag identifies the caller (IP, user/tenant id,
|
|
33
|
+
// auth target, payload digest). IPv6 addresses and handler names contain ":",
|
|
34
|
+
// so no later segment can be kept safely by parsing; the client already knows
|
|
35
|
+
// which handler/route it called.
|
|
36
|
+
export function toPublicBucketName(bucket) {
|
|
37
|
+
const separator = bucket.indexOf(":");
|
|
38
|
+
return separator === -1 ? bucket : bucket.slice(0, separator);
|
|
39
|
+
}
|
|
40
|
+
// Rotating the JWT secret only resets these buckets (new digests); they are
|
|
41
|
+
// ephemeral (TTL 2x window), so no migration is needed.
|
|
42
|
+
export function createPayloadDigest(masterSecret) {
|
|
43
|
+
const key = derivePurposeSecret(masterSecret, PAYLOAD_BUCKET_PURPOSE);
|
|
44
|
+
return (value) => createHmac("sha256", key).update(value).digest("hex");
|
|
45
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { type BucketContext, type BucketResult, buildBucketKey } from "./bucket.js";
|
|
2
|
-
export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, } from "./middleware.js";
|
|
1
|
+
export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
|
|
2
|
+
export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.js";
|
|
3
3
|
export { createRateLimitResolver, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.js";
|
package/dist/rate-limit/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { buildBucketKey } from "./bucket.js";
|
|
2
|
-
export { authEndpointRateLimit, globalIpRateLimit, } from "./middleware.js";
|
|
1
|
+
export { buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
|
|
2
|
+
export { authEndpointRateLimit, globalIpRateLimit, httpRouteRateLimit, } from "./middleware.js";
|
|
3
3
|
export { createRateLimitResolver, } from "./resolver.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { type ClientIpResolver } from "../api/client-ip.js";
|
|
3
|
+
import type { HttpRouteRateLimit } from "../engine/types/http-route.js";
|
|
3
4
|
import type { RateLimitResolver } from "./resolver.js";
|
|
4
5
|
export type GlobalIpRateLimitOptions = {
|
|
5
6
|
readonly resolver: RateLimitResolver;
|
|
@@ -11,6 +12,14 @@ export type GlobalIpRateLimitOptions = {
|
|
|
11
12
|
readonly onFailClosed?: (err: unknown) => void;
|
|
12
13
|
};
|
|
13
14
|
export declare function globalIpRateLimit(opts: GlobalIpRateLimitOptions): MiddlewareHandler;
|
|
15
|
+
export type HttpRouteRateLimitOptions = {
|
|
16
|
+
readonly resolver: RateLimitResolver;
|
|
17
|
+
readonly rateLimit: HttpRouteRateLimit;
|
|
18
|
+
readonly routeKey: string;
|
|
19
|
+
readonly clientIpResolver: ClientIpResolver;
|
|
20
|
+
readonly onFailClosed?: (err: unknown) => void;
|
|
21
|
+
};
|
|
22
|
+
export declare function httpRouteRateLimit(opts: HttpRouteRateLimitOptions): MiddlewareHandler;
|
|
14
23
|
export type AuthEndpointRateLimitOptions = {
|
|
15
24
|
readonly resolver: RateLimitResolver;
|
|
16
25
|
readonly limit?: number;
|
|
@@ -2,6 +2,7 @@ import { isAuthRateLimitExempt } from "../api/api-constants.js";
|
|
|
2
2
|
import { clientIpSourceFromHonoContext, createClientIpResolver, } from "../api/client-ip.js";
|
|
3
3
|
import { requestContext } from "../api/request-context.js";
|
|
4
4
|
import { RateLimitError, serializeError } from "../errors/index.js";
|
|
5
|
+
import { toPublicBucketName } from "./bucket.js";
|
|
5
6
|
export function globalIpRateLimit(opts) {
|
|
6
7
|
const limit = opts.limit ?? 1000;
|
|
7
8
|
const windowSeconds = opts.windowSeconds ?? 60;
|
|
@@ -33,6 +34,34 @@ export function globalIpRateLimit(opts) {
|
|
|
33
34
|
await next();
|
|
34
35
|
};
|
|
35
36
|
}
|
|
37
|
+
// Per-route limit for r.httpRoute({ rateLimit }). Fail-closed like L1: these
|
|
38
|
+
// routes are anonymous, so an outage must not turn into an unbounded flood.
|
|
39
|
+
export function httpRouteRateLimit(opts) {
|
|
40
|
+
const { rateLimit, routeKey } = opts;
|
|
41
|
+
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("http-route");
|
|
42
|
+
const config = {
|
|
43
|
+
limit: rateLimit.limit,
|
|
44
|
+
windowSeconds: rateLimit.windowSeconds,
|
|
45
|
+
...(rateLimit.cost !== undefined && { cost: rateLimit.cost }),
|
|
46
|
+
};
|
|
47
|
+
return async (c, next) => {
|
|
48
|
+
const ip = opts.clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
49
|
+
if (!ip)
|
|
50
|
+
return next();
|
|
51
|
+
const bucket = rateLimit.per === "ip" ? `http:${ip}` : `http:${ip}:${routeKey}`;
|
|
52
|
+
try {
|
|
53
|
+
const decision = await opts.resolver.check(bucket, config);
|
|
54
|
+
if (!decision.allowed)
|
|
55
|
+
return respondRateLimited(c, decision, bucket);
|
|
56
|
+
setRateLimitHeaders(c, decision);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
onFailClosed(e);
|
|
60
|
+
return c.json({ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } }, 503);
|
|
61
|
+
}
|
|
62
|
+
await next();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
36
65
|
export function authEndpointRateLimit(opts) {
|
|
37
66
|
const limit = opts.limit ?? 5;
|
|
38
67
|
const windowSeconds = opts.windowSeconds ?? 60;
|
|
@@ -70,7 +99,9 @@ function defaultOnFailClosed(label) {
|
|
|
70
99
|
// override this with a structured logger; the default keeps the
|
|
71
100
|
// noise visible if no logger is wired.
|
|
72
101
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
73
|
-
console.error(`[rate-limit ${label}] fail-closed (refusing request):`,
|
|
102
|
+
console.error(`[rate-limit ${label}] fail-closed (refusing request):`,
|
|
103
|
+
// ioredis errors can carry the command args, i.e. the bucket key with IP/target.
|
|
104
|
+
err instanceof Error ? err.message : String(err));
|
|
74
105
|
};
|
|
75
106
|
}
|
|
76
107
|
function setRateLimitHeaders(c, decision) {
|
|
@@ -87,7 +118,7 @@ function respondRateLimited(c, decision, bucket) {
|
|
|
87
118
|
// dispatcher path. serializeError adds i18nKey, requestId, timestamp —
|
|
88
119
|
// fields a hand-rolled body would silently miss.
|
|
89
120
|
const err = new RateLimitError({
|
|
90
|
-
bucket,
|
|
121
|
+
bucket: toPublicBucketName(bucket),
|
|
91
122
|
limit: decision.limit,
|
|
92
123
|
windowSeconds: decision.windowSeconds,
|
|
93
124
|
remaining: decision.remaining,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
5
5
|
import { RateLimitError } from "../errors/index.js";
|
|
6
6
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
7
|
+
import { toPublicBucketName } from "./bucket.js";
|
|
7
8
|
// Token-Bucket rate limiter, atomic via Redis Lua. One round-trip per
|
|
8
9
|
// check — the script computes the bucket state inline and either deducts
|
|
9
10
|
// or rejects.
|
|
@@ -148,7 +149,7 @@ export function createRateLimitResolver(opts) {
|
|
|
148
149
|
if (decision.allowed)
|
|
149
150
|
return decision;
|
|
150
151
|
throw new RateLimitError({
|
|
151
|
-
bucket,
|
|
152
|
+
bucket: toPublicBucketName(bucket),
|
|
152
153
|
limit: decision.limit,
|
|
153
154
|
windowSeconds: decision.windowSeconds,
|
|
154
155
|
remaining: decision.remaining,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CacheSyncBus, ClosableCacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
2
|
+
import { type RedisClientOptions } from "./client.js";
|
|
3
|
+
import { type PubSubSignal } from "./pubsub-signal.js";
|
|
4
|
+
export type { CacheSyncBus, ClosableCacheSyncBus };
|
|
5
|
+
export declare function createLocalCacheSyncBus(): ClosableCacheSyncBus;
|
|
6
|
+
export type CacheSyncBusOverSignalOptions = {
|
|
7
|
+
readonly channel: string;
|
|
8
|
+
readonly originId?: string;
|
|
9
|
+
readonly resyncMinIntervalMs?: number;
|
|
10
|
+
readonly now?: () => number;
|
|
11
|
+
};
|
|
12
|
+
export declare function createCacheSyncBusOverSignal(signal: PubSubSignal, opts: CacheSyncBusOverSignalOptions): ClosableCacheSyncBus;
|
|
13
|
+
export type RedisCacheSyncBusOptions = {
|
|
14
|
+
readonly redisUrl: string;
|
|
15
|
+
readonly clientOptions?: RedisClientOptions;
|
|
16
|
+
readonly channelPrefix?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function createRedisCacheSyncBus(opts: RedisCacheSyncBusOptions): ClosableCacheSyncBus;
|
|
19
|
+
export declare function createDefaultCacheSyncBus(envVars?: Record<string, string | undefined>): ClosableCacheSyncBus;
|
|
20
|
+
export type ResolvedCacheSyncBus = {
|
|
21
|
+
readonly bus: CacheSyncBus | undefined;
|
|
22
|
+
readonly owned: ClosableCacheSyncBus | undefined;
|
|
23
|
+
};
|
|
24
|
+
export declare function resolveCacheSyncBus(explicit: CacheSyncBus | null | undefined): ResolvedCacheSyncBus;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
2
|
+
import { redisClientOptionsFromEnv } from "./client.js";
|
|
3
|
+
import { createRedisPubSubSignal } from "./pubsub-signal.js";
|
|
4
|
+
const log = createFallbackLogger("kumiko:cache-sync");
|
|
5
|
+
const CACHE_SYNC_CHANNEL = "kumiko:cache-sync";
|
|
6
|
+
const RESYNC_MIN_INTERVAL_MS = 5_000;
|
|
7
|
+
function isCacheSyncEnvelope(value) {
|
|
8
|
+
return (typeof value === "object" &&
|
|
9
|
+
value !== null &&
|
|
10
|
+
"origin" in value &&
|
|
11
|
+
typeof value.origin === "string" &&
|
|
12
|
+
"topic" in value &&
|
|
13
|
+
typeof value.topic === "string" &&
|
|
14
|
+
"message" in value);
|
|
15
|
+
}
|
|
16
|
+
function createLocalFanout() {
|
|
17
|
+
const subscribers = new Map();
|
|
18
|
+
const resyncListeners = new Set();
|
|
19
|
+
return {
|
|
20
|
+
deliver(topic, message) {
|
|
21
|
+
const listeners = subscribers.get(topic);
|
|
22
|
+
// skip: nobody in this process listens on the topic
|
|
23
|
+
if (!listeners)
|
|
24
|
+
return;
|
|
25
|
+
for (const listener of [...listeners]) {
|
|
26
|
+
// A failing listener must not starve the others nor the publishing write or the redis event loop.
|
|
27
|
+
try {
|
|
28
|
+
listener(message);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log.error("subscriber threw", { topic, err });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
subscribe(topic, listener) {
|
|
36
|
+
let listeners = subscribers.get(topic);
|
|
37
|
+
if (!listeners) {
|
|
38
|
+
listeners = new Set();
|
|
39
|
+
subscribers.set(topic, listeners);
|
|
40
|
+
}
|
|
41
|
+
listeners.add(listener);
|
|
42
|
+
return () => {
|
|
43
|
+
listeners.delete(listener);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
onResync(listener) {
|
|
47
|
+
resyncListeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
resyncListeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
fireResync() {
|
|
53
|
+
for (const listener of [...resyncListeners]) {
|
|
54
|
+
try {
|
|
55
|
+
listener();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
log.error("resync listener threw", { err });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function createLocalCacheSyncBus() {
|
|
65
|
+
const fanout = createLocalFanout();
|
|
66
|
+
return {
|
|
67
|
+
publish: fanout.deliver,
|
|
68
|
+
subscribe: fanout.subscribe,
|
|
69
|
+
onResync: fanout.onResync,
|
|
70
|
+
async close() { },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function createCacheSyncBusOverSignal(signal, opts) {
|
|
74
|
+
const origin = opts.originId ?? crypto.randomUUID();
|
|
75
|
+
const resyncMinIntervalMs = opts.resyncMinIntervalMs ?? RESYNC_MIN_INTERVAL_MS;
|
|
76
|
+
const now = opts.now ?? Date.now;
|
|
77
|
+
const fanout = createLocalFanout();
|
|
78
|
+
signal.onMessage((_channel, payload) => {
|
|
79
|
+
// skip: foreign or malformed payload on the channel
|
|
80
|
+
if (!isCacheSyncEnvelope(payload))
|
|
81
|
+
return;
|
|
82
|
+
// skip: own echo, already delivered locally by publish()
|
|
83
|
+
if (payload.origin === origin)
|
|
84
|
+
return;
|
|
85
|
+
fanout.deliver(payload.topic, payload.message);
|
|
86
|
+
});
|
|
87
|
+
// Reconnect bursts collapse into one reload per interval; the trailing timer
|
|
88
|
+
// keeps a reconnect inside the window from being dropped.
|
|
89
|
+
let lastResyncAt = Number.NEGATIVE_INFINITY;
|
|
90
|
+
let pendingResync;
|
|
91
|
+
let closed = false;
|
|
92
|
+
const runResync = () => {
|
|
93
|
+
pendingResync = undefined;
|
|
94
|
+
lastResyncAt = now();
|
|
95
|
+
fanout.fireResync();
|
|
96
|
+
};
|
|
97
|
+
signal.onReconnect(() => {
|
|
98
|
+
// skip: bus closed, or a resync is already scheduled
|
|
99
|
+
if (closed || pendingResync !== undefined)
|
|
100
|
+
return;
|
|
101
|
+
const wait = lastResyncAt + resyncMinIntervalMs - now();
|
|
102
|
+
if (wait <= 0) {
|
|
103
|
+
runResync();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
pendingResync = setTimeout(runResync, wait);
|
|
107
|
+
pendingResync.unref();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
publish(topic, message) {
|
|
112
|
+
fanout.deliver(topic, message);
|
|
113
|
+
const envelope = { origin, topic, message };
|
|
114
|
+
signal.publish(opts.channel, envelope);
|
|
115
|
+
},
|
|
116
|
+
subscribe: fanout.subscribe,
|
|
117
|
+
onResync: fanout.onResync,
|
|
118
|
+
async close() {
|
|
119
|
+
closed = true;
|
|
120
|
+
if (pendingResync !== undefined)
|
|
121
|
+
clearTimeout(pendingResync);
|
|
122
|
+
await signal.close();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function createRedisCacheSyncBus(opts) {
|
|
127
|
+
const channel = `${opts.channelPrefix ?? ""}${CACHE_SYNC_CHANNEL}`;
|
|
128
|
+
const signal = createRedisPubSubSignal({
|
|
129
|
+
redisUrl: opts.redisUrl,
|
|
130
|
+
channelPattern: channel,
|
|
131
|
+
label: "cache-sync",
|
|
132
|
+
...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
|
|
133
|
+
});
|
|
134
|
+
return createCacheSyncBusOverSignal(signal, { channel });
|
|
135
|
+
}
|
|
136
|
+
// Same REDIS_URL gate as createDefaultSseBroker: replicas > 1 are expected to
|
|
137
|
+
// set it. Without it invalidation stays inside this process.
|
|
138
|
+
export function createDefaultCacheSyncBus(envVars = process.env) {
|
|
139
|
+
const redisUrl = envVars["REDIS_URL"];
|
|
140
|
+
return redisUrl
|
|
141
|
+
? createRedisCacheSyncBus({ redisUrl, clientOptions: redisClientOptionsFromEnv(envVars) })
|
|
142
|
+
: createLocalCacheSyncBus();
|
|
143
|
+
}
|
|
144
|
+
// An explicit bus wins (caller owns it), null opts out, undefined builds the default.
|
|
145
|
+
export function resolveCacheSyncBus(explicit) {
|
|
146
|
+
if (explicit)
|
|
147
|
+
return { bus: explicit, owned: undefined };
|
|
148
|
+
if (explicit === null)
|
|
149
|
+
return { bus: undefined, owned: undefined };
|
|
150
|
+
const owned = createDefaultCacheSyncBus();
|
|
151
|
+
return { bus: owned, owned };
|
|
152
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types/identifiers.js";
|
|
2
|
+
export declare const CACHE_SYNC_TOPICS: {
|
|
3
|
+
readonly tenantConfig: "tenant-config";
|
|
4
|
+
readonly tierAssignment: "tier-assignment";
|
|
5
|
+
};
|
|
6
|
+
export type TenantConfigSyncMessage = {
|
|
7
|
+
readonly tenantId: TenantId;
|
|
8
|
+
readonly key?: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly scope: "all";
|
|
11
|
+
readonly key?: string;
|
|
12
|
+
};
|
|
13
|
+
export type TierAssignmentSyncMessage = {
|
|
14
|
+
readonly tenantId: TenantId;
|
|
15
|
+
};
|
|
16
|
+
export declare function isTenantConfigSyncMessage(value: unknown): value is TenantConfigSyncMessage;
|
|
17
|
+
export declare function isTierAssignmentSyncMessage(value: unknown): value is TierAssignmentSyncMessage;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const CACHE_SYNC_TOPICS = {
|
|
2
|
+
tenantConfig: "tenant-config",
|
|
3
|
+
tierAssignment: "tier-assignment",
|
|
4
|
+
};
|
|
5
|
+
function hasOptionalStringKey(value) {
|
|
6
|
+
return !("key" in value) || value.key === undefined || typeof value.key === "string";
|
|
7
|
+
}
|
|
8
|
+
export function isTenantConfigSyncMessage(value) {
|
|
9
|
+
if (typeof value !== "object" || value === null)
|
|
10
|
+
return false;
|
|
11
|
+
if (!hasOptionalStringKey(value))
|
|
12
|
+
return false;
|
|
13
|
+
if ("scope" in value)
|
|
14
|
+
return value.scope === "all";
|
|
15
|
+
return "tenantId" in value && typeof value.tenantId === "string";
|
|
16
|
+
}
|
|
17
|
+
export function isTierAssignmentSyncMessage(value) {
|
|
18
|
+
return (typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
"tenantId" in value &&
|
|
21
|
+
typeof value.tenantId === "string");
|
|
22
|
+
}
|