@cosmicdrift/kumiko-framework 0.371.1 → 0.373.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/AGENTS.md +4 -1
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/pdf-audit-export.md +1 -1
- package/agents/examples/rate-limiting.md +65 -1
- package/agents/examples/user-data-rights.md +176 -0
- package/dist/api/jwt.js +2 -1
- package/dist/api/observability-middleware.d.ts +1 -2
- package/dist/api/observability-middleware.js +9 -8
- package/dist/api/pii-leak-guard.js +2 -1
- package/dist/api/route-label.d.ts +3 -0
- package/dist/api/route-label.js +17 -0
- package/dist/api/server.d.ts +1 -2
- package/dist/api/server.js +3 -5
- package/dist/config/index.d.ts +5 -0
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/located-timestamp.d.ts +7 -0
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +8 -2
- package/dist/db/table-builder.d.ts +45 -11
- package/dist/db/tenant-db.js +3 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +47 -16
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/factories.d.ts +2 -1
- package/dist/engine/factories.js +2 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/membership-roles.d.ts +1 -0
- package/dist/engine/membership-roles.js +8 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/entrypoint/index.d.ts +1 -0
- package/dist/entrypoint/index.js +0 -2
- package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
- package/dist/files/bind-pending-file-refs.d.ts +31 -1
- package/dist/files/bind-pending-file-refs.js +80 -6
- package/dist/files/create-file-ref-from-bytes.js +12 -6
- package/dist/files/create-file-ref.d.ts +20 -2
- package/dist/files/create-file-ref.js +10 -1
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +25 -0
- package/dist/files/file-handle.d.ts +4 -1
- package/dist/files/file-handle.js +30 -4
- package/dist/files/file-ref-entity.d.ts +7 -7
- package/dist/files/file-ref-table.d.ts +7 -7
- package/dist/files/file-routes.js +6 -1
- package/dist/files/index.d.ts +5 -1
- package/dist/files/index.js +4 -1
- package/dist/files/load-save-file-refs.js +3 -1
- package/dist/files/provider-resolver.js +3 -1
- package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
- package/dist/files/sweep-orphaned-uploads.js +53 -0
- package/dist/files/upload-ledger.d.ts +10 -0
- package/dist/files/upload-ledger.js +47 -0
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/request-locale.d.ts +15 -4
- package/dist/i18n/request-locale.js +26 -9
- package/dist/jobs/job-runner.js +2 -4
- package/dist/pipeline/dispatch-shared.js +57 -39
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/rate-limit/index.d.ts +2 -1
- package/dist/rate-limit/index.js +1 -0
- package/dist/rate-limit/resolver.d.ts +1 -1
- package/dist/rate-limit/resolver.js +104 -2
- package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
- package/dist/rate-limit/unlimited-resolver.js +22 -0
- package/dist/schema-cli.js +46 -5
- package/package.json +5 -5
- package/src/changes.json +55 -0
- package/src/scripts/codemod/README.md +22 -0
- package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
|
@@ -16,6 +16,7 @@ import { fileRefsTable } from "./file-ref-table.js";
|
|
|
16
16
|
import { RecordWriteDecisions } from "./record-write-check.js";
|
|
17
17
|
import { neutralFileNameForMimeType } from "./resolve-content-type.js";
|
|
18
18
|
import { parseMaxSize, resolveServedContentType } from "./types.js";
|
|
19
|
+
import { createFileUploadLedger } from "./upload-ledger.js";
|
|
19
20
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
20
21
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
21
22
|
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
@@ -171,13 +172,17 @@ export function createFileRoutes(options) {
|
|
|
171
172
|
}
|
|
172
173
|
const data = new Uint8Array(await file.arrayBuffer());
|
|
173
174
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
175
|
+
const uploadLedger = createFileUploadLedger(db, user.tenantId);
|
|
174
176
|
const outcome = await storeFileAsFileRef({
|
|
175
177
|
user,
|
|
176
178
|
tenantDb: tenantDbFor(user.tenantId),
|
|
177
179
|
rowInOwnTransaction: true,
|
|
178
180
|
registry: options.registry,
|
|
179
181
|
defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
180
|
-
writeBytes: (key, bytes, mimeType) =>
|
|
182
|
+
writeBytes: async (key, bytes, mimeType) => {
|
|
183
|
+
await uploadLedger.record(key);
|
|
184
|
+
await storageProvider.write(key, bytes, mimeType);
|
|
185
|
+
},
|
|
181
186
|
deleteBytes: (key) => storageProvider.delete(key),
|
|
182
187
|
}, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
|
|
183
188
|
if (outcome.kind === "rejected")
|
package/dist/files/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
export type { BindPendingFileRefOutcome, PendingFileRef } from "./bind-pending-file-refs.ts";
|
|
2
|
+
export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
|
|
1
3
|
export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.ts";
|
|
2
4
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
|
|
3
5
|
export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
|
|
4
6
|
export type { FileContext, FileHandle } from "./file-handle.ts";
|
|
5
|
-
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.ts";
|
|
7
|
+
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
|
|
6
8
|
export { fileRefEntity } from "./file-ref-entity.ts";
|
|
7
9
|
export { fileRefsTable } from "./file-ref-table.ts";
|
|
8
10
|
export type { FileAccessDecision, FileAccessGuard, FileRef, FileRoutesOptions, } from "./file-routes.ts";
|
|
@@ -14,7 +16,9 @@ export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, Fil
|
|
|
14
16
|
export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.ts";
|
|
15
17
|
export { resolveContentType } from "./resolve-content-type.ts";
|
|
16
18
|
export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
|
|
19
|
+
export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.ts";
|
|
17
20
|
export type { FileContentValidationResult, FileMetadata, FileStorageProvider, FileValidationOptions, SignedUrlOptions, UploadMimeTypeResolution, WriteStreamOptions, } from "./types.ts";
|
|
18
21
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.ts";
|
|
22
|
+
export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.ts";
|
|
19
23
|
export type { ZipEntry } from "./zip-stream.ts";
|
|
20
24
|
export { createZipStream } from "./zip-stream.ts";
|
package/dist/files/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
|
|
1
2
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
|
|
2
3
|
export { createFilesFeature, FILES_FEATURE } from "./feature.js";
|
|
3
4
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
4
5
|
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
5
|
-
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
|
|
6
|
+
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
|
|
6
7
|
export { fileRefEntity } from "./file-ref-entity.js";
|
|
7
8
|
export { fileRefsTable } from "./file-ref-table.js";
|
|
8
9
|
export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
|
|
@@ -11,5 +12,7 @@ export { createLocalProvider } from "./local-provider.js";
|
|
|
11
12
|
export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.js";
|
|
12
13
|
export { resolveContentType } from "./resolve-content-type.js";
|
|
13
14
|
export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
|
|
15
|
+
export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
|
|
14
16
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.js";
|
|
17
|
+
export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
|
|
15
18
|
export { createZipStream } from "./zip-stream.js";
|
|
@@ -33,12 +33,14 @@ export async function loadFileRefRows(db, ids, tenantId) {
|
|
|
33
33
|
// skip: nothing referenced, nothing to load
|
|
34
34
|
if (ids.length === 0)
|
|
35
35
|
return new Map();
|
|
36
|
+
// Versions before rows: a bind committing in between leaves a stale row paired with an older
|
|
37
|
+
// version, so the binding update conflicts instead of overwriting the winner's entityId.
|
|
38
|
+
const streamVersions = await getStreamVersions(tenantDbRunner(db), [...ids], db.tenantId);
|
|
36
39
|
const rows = await selectMany(db, fileRefsTable, {
|
|
37
40
|
id: { in: [...ids] },
|
|
38
41
|
tenantId,
|
|
39
42
|
isDeleted: false,
|
|
40
43
|
});
|
|
41
|
-
const streamVersions = await getStreamVersions(tenantDbRunner(db), rows.map((row) => String(row["id"])), db.tenantId);
|
|
42
44
|
return new Map(rows.map((row) => {
|
|
43
45
|
const id = String(row["id"]);
|
|
44
46
|
const streamVersion = streamVersions.get(id);
|
|
@@ -15,6 +15,7 @@ import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT
|
|
|
15
15
|
import { SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
16
16
|
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
17
17
|
import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
|
|
18
|
+
import { withUploadLedger } from "./upload-ledger.js";
|
|
18
19
|
const FEATURE_NAME = "file-foundation";
|
|
19
20
|
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
20
21
|
// instead of casting blind.
|
|
@@ -95,7 +96,7 @@ export function makeFileProviderResolver(deps) {
|
|
|
95
96
|
cache.delete(message.tenantId);
|
|
96
97
|
});
|
|
97
98
|
deps.cacheSync?.onResync(() => cache.clear());
|
|
98
|
-
|
|
99
|
+
const resolveProvider = (tenantId) => {
|
|
99
100
|
const cached = cache.get(tenantId);
|
|
100
101
|
if (cached)
|
|
101
102
|
return cached;
|
|
@@ -118,4 +119,5 @@ export function makeFileProviderResolver(deps) {
|
|
|
118
119
|
cache.set(tenantId, built);
|
|
119
120
|
return built;
|
|
120
121
|
};
|
|
122
|
+
return deps.db ? withUploadLedger(resolveProvider, deps.db) : resolveProvider;
|
|
121
123
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
2
|
+
import type { Logger } from "@cosmicdrift/kumiko-types/logger";
|
|
3
|
+
import { type TenantDb } from "../db/tenant-db.ts";
|
|
4
|
+
export declare const ORPHANED_UPLOAD_SWEEP_BATCH = 500;
|
|
5
|
+
export declare const DEFAULT_UPLOAD_SWEEP_GRACE_HOURS = 24;
|
|
6
|
+
export declare const MIN_UPLOAD_SWEEP_GRACE_HOURS = 1;
|
|
7
|
+
type SweepLog = Pick<Logger, "warn"> | undefined;
|
|
8
|
+
export declare function sweepOrphanedUploads(args: {
|
|
9
|
+
readonly db: TenantDb;
|
|
10
|
+
readonly files: FileContext | undefined;
|
|
11
|
+
readonly now: Temporal.Instant;
|
|
12
|
+
readonly graceHours: number;
|
|
13
|
+
readonly log: SweepLog;
|
|
14
|
+
readonly pageSize?: number;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { selectMany } from "../db/query.js";
|
|
2
|
+
import { runInOwnTransaction } from "../db/tenant-db.js";
|
|
3
|
+
import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
|
|
4
|
+
import { fileRefsTable } from "./file-ref-table.js";
|
|
5
|
+
import { fileUploadLedgerTable, takeUploadLedgerEntry } from "./upload-ledger.js";
|
|
6
|
+
export const ORPHANED_UPLOAD_SWEEP_BATCH = 500;
|
|
7
|
+
export const DEFAULT_UPLOAD_SWEEP_GRACE_HOURS = 24;
|
|
8
|
+
export const MIN_UPLOAD_SWEEP_GRACE_HOURS = 1;
|
|
9
|
+
// Each candidate gets its own short transaction that takes the ledger row first. A commit that
|
|
10
|
+
// holds the row makes the sweep wait and then find it gone; a sweep that took the row first makes
|
|
11
|
+
// the commit find it gone and fail, so bytes and fileRef row can never end up apart.
|
|
12
|
+
async function sweepOrphanedUpload(txDb, files, storageKey) {
|
|
13
|
+
const taken = await takeUploadLedgerEntry(txDb, storageKey);
|
|
14
|
+
// skip: a commit took the row in the meantime and owns the bytes now
|
|
15
|
+
if (!taken)
|
|
16
|
+
return;
|
|
17
|
+
const committedRows = await selectMany(txDb, fileRefsTable, { storageKey }, { limit: 1, fields: ["id"] });
|
|
18
|
+
// skip: a fileRef row claims the bytes, only the stale ledger row goes
|
|
19
|
+
if (committedRows.length > 0)
|
|
20
|
+
return;
|
|
21
|
+
const failedKeys = await deleteFileRefBytes(files, { storageKey }, "upload-ledger-sweep");
|
|
22
|
+
if (failedKeys.length > 0)
|
|
23
|
+
throw new Error("bytes not fully deleted");
|
|
24
|
+
}
|
|
25
|
+
// Deletes the bytes (original and derivatives) of uploads that never got a fileRef row within the
|
|
26
|
+
// grace period. Walks the ledger page by page along storage_key, so rows that keep failing or are
|
|
27
|
+
// locked cannot hide the rows behind them; a failed row rolls back and the next run retries it.
|
|
28
|
+
export async function sweepOrphanedUploads(args) {
|
|
29
|
+
const { db, files, now, graceHours, log } = args;
|
|
30
|
+
const pageSize = args.pageSize ?? ORPHANED_UPLOAD_SWEEP_BATCH;
|
|
31
|
+
if (!files) {
|
|
32
|
+
log?.warn("[upload-ledger-sweep] no file storage available, orphaned uploads kept");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const cutoff = now.subtract({ hours: graceHours });
|
|
36
|
+
let cursor;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const rows = await selectMany(db, fileUploadLedgerTable, { createdAt: { lt: cutoff }, ...(cursor !== undefined && { storageKey: { gt: cursor } }) }, { orderBy: { col: "storageKey", direction: "asc" }, fields: ["storageKey"], limit: pageSize });
|
|
39
|
+
const lastRow = rows[rows.length - 1];
|
|
40
|
+
// skip: an empty page means every due row has been visited
|
|
41
|
+
if (lastRow === undefined)
|
|
42
|
+
return;
|
|
43
|
+
cursor = lastRow.storageKey;
|
|
44
|
+
for (const { storageKey } of rows) {
|
|
45
|
+
try {
|
|
46
|
+
await runInOwnTransaction(db, (txDb) => sweepOrphanedUpload(txDb, files, storageKey));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
log?.warn(`[upload-ledger-sweep] orphaned upload not deleted, retried next run: ${err instanceof Error ? err.message : String(err)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FileProviderResolver, FileUploadLedger } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
2
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-types/file-storage-provider-types";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
4
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
5
|
+
import { type EntityTableMeta } from "../db/entity-table-meta.ts";
|
|
6
|
+
import { type TenantDb } from "../db/tenant-db.ts";
|
|
7
|
+
export declare const fileUploadLedgerTable: EntityTableMeta;
|
|
8
|
+
export declare function createFileUploadLedger(db: DbConnection | TenantDb, tenantId: TenantId): FileUploadLedger;
|
|
9
|
+
export declare function withUploadLedger(resolver: (tenantId: TenantId) => Promise<FileStorageProvider>, db: DbConnection | TenantDb): FileProviderResolver;
|
|
10
|
+
export declare function takeUploadLedgerEntry(txDb: TenantDb, storageKey: string): Promise<boolean>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineUnmanagedTable } from "../db/entity-table-meta.js";
|
|
2
|
+
import { createTenantDb } from "../db/tenant-db.js";
|
|
3
|
+
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
4
|
+
const CREATED_AT_INDEX = "store_file_upload_ledger_created_at_idx";
|
|
5
|
+
// One row per upload whose bytes are in storage but whose fileRef row is not committed yet. The
|
|
6
|
+
// commit path deletes it inside the transaction that creates the fileRef; the sweep finds the
|
|
7
|
+
// rows that never got there. Unmanaged: a coordination aid, not state with an event history.
|
|
8
|
+
export const fileUploadLedgerTable = defineUnmanagedTable({
|
|
9
|
+
tableName: "store_file_upload_ledger",
|
|
10
|
+
indexes: [{ name: CREATED_AT_INDEX, columns: ["created_at"] }],
|
|
11
|
+
columns: [
|
|
12
|
+
{ name: "storage_key", pgType: "text", notNull: true, primaryKey: true },
|
|
13
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
14
|
+
{ name: "created_at", pgType: "timestamptz", notNull: true },
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
function isTenantDb(db) {
|
|
18
|
+
return "tenantId" in db && "fetchOne" in db && typeof db.fetchOne === "function";
|
|
19
|
+
}
|
|
20
|
+
// The ledger writes on a connection of its own, never on a handler transaction: a rollback of the
|
|
21
|
+
// handler must not take the row with it, or the bytes it uploaded would be unfindable.
|
|
22
|
+
export function createFileUploadLedger(db, tenantId) {
|
|
23
|
+
const runner = isTenantDb(db) ? tenantDbRunner(db) : db;
|
|
24
|
+
const ledgerDb = createTenantDb({ db: runner, tenantId });
|
|
25
|
+
return {
|
|
26
|
+
record: async (storageKey) => {
|
|
27
|
+
await ledgerDb.insertOne(fileUploadLedgerTable, {
|
|
28
|
+
storageKey,
|
|
29
|
+
tenantId,
|
|
30
|
+
createdAt: Temporal.Now.instant(),
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
forget: async (storageKey) => {
|
|
34
|
+
await ledgerDb.deleteMany(fileUploadLedgerTable, { storageKey });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function withUploadLedger(resolver, db) {
|
|
39
|
+
return Object.assign(resolver, {
|
|
40
|
+
uploadLedgerFor: (tenantId) => createFileUploadLedger(db, tenantId),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// Runs inside the transaction that creates the fileRef row. False means no ledger row was left to
|
|
44
|
+
// take: the sweep already removed the bytes, so the row must not be committed.
|
|
45
|
+
export async function takeUploadLedgerEntry(txDb, storageKey) {
|
|
46
|
+
return (await txDb.deleteMany(fileUploadLedgerTable, { storageKey })) > 0;
|
|
47
|
+
}
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Registry } from "../engine/types/index.ts";
|
|
2
2
|
export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.ts";
|
|
3
|
-
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.ts";
|
|
3
|
+
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, rankAcceptLanguage, rankPreferredLocales, resolveHeaderLocale, } from "./request-locale.ts";
|
|
4
4
|
export { SETTINGS_HUB_I18N } from "./settings-hub-keys.ts";
|
|
5
5
|
export type I18nOptions = {
|
|
6
6
|
defaultLocale: string;
|
package/dist/i18n/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveTranslationValue } from "../ui-types/plural.js";
|
|
2
2
|
export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
3
|
-
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
|
|
3
|
+
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, rankAcceptLanguage, rankPreferredLocales, resolveHeaderLocale, } from "./request-locale.js";
|
|
4
4
|
export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
|
|
5
5
|
export function createI18n(registry, options) {
|
|
6
6
|
const translations = registry.getAllTranslations();
|
|
@@ -5,12 +5,23 @@ export declare function isValidLocaleTag(value: string): boolean;
|
|
|
5
5
|
* hit registered keys regardless of header casing. */
|
|
6
6
|
export declare function canonicalizeLocaleTag(tag: string): string;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Tags
|
|
8
|
+
* Well-formed tags of an Accept-Language header (RFC 9110 §12.5.4) in
|
|
9
|
+
* preference order: "tag;q=x" pairs sorted by q descending, header order
|
|
10
|
+
* breaking ties. Tags with q=0 (explicitly excluded) and malformed tags are
|
|
11
|
+
* dropped. Tags are returned as sent, not canonicalized or deduped.
|
|
12
12
|
*/
|
|
13
|
+
export declare function rankAcceptLanguage(header: string | undefined): string[];
|
|
14
|
+
/** Picks the best well-formed tag from an Accept-Language header, see rankAcceptLanguage. */
|
|
13
15
|
export declare function pickAcceptLanguage(header: string | undefined): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Ordered locale preference of a page request: a valid `?lang=` first, then
|
|
18
|
+
* every Accept-Language tag by q. Canonicalized and deduped (case-insensitive),
|
|
19
|
+
* so a resolver can walk the list and take the first locale it has content for.
|
|
20
|
+
*/
|
|
21
|
+
export declare function rankPreferredLocales(options: {
|
|
22
|
+
readonly queryLang?: string | null | undefined;
|
|
23
|
+
readonly acceptLanguage?: string | null | undefined;
|
|
24
|
+
}): string[];
|
|
14
25
|
/**
|
|
15
26
|
* Request-layer resolution: an explicit, validated X-Locale header wins;
|
|
16
27
|
* otherwise the best Accept-Language tag; otherwise undefined — no signal
|
|
@@ -33,15 +33,15 @@ export function canonicalizeLocaleTag(tag) {
|
|
|
33
33
|
.join("-");
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Tags
|
|
36
|
+
* Well-formed tags of an Accept-Language header (RFC 9110 §12.5.4) in
|
|
37
|
+
* preference order: "tag;q=x" pairs sorted by q descending, header order
|
|
38
|
+
* breaking ties. Tags with q=0 (explicitly excluded) and malformed tags are
|
|
39
|
+
* dropped. Tags are returned as sent, not canonicalized or deduped.
|
|
40
40
|
*/
|
|
41
|
-
export function
|
|
41
|
+
export function rankAcceptLanguage(header) {
|
|
42
42
|
if (header === undefined || header.length === 0)
|
|
43
|
-
return
|
|
44
|
-
|
|
43
|
+
return [];
|
|
44
|
+
return header
|
|
45
45
|
.split(",")
|
|
46
46
|
.map((part, index) => {
|
|
47
47
|
const [tagRaw, ...params] = part.trim().split(";");
|
|
@@ -54,8 +54,25 @@ export function pickAcceptLanguage(header) {
|
|
|
54
54
|
return { tag, q, index };
|
|
55
55
|
})
|
|
56
56
|
.filter((c) => c !== undefined && c.q > 0)
|
|
57
|
-
.sort((a, b) => b.q - a.q || a.index - b.index)
|
|
58
|
-
|
|
57
|
+
.sort((a, b) => b.q - a.q || a.index - b.index)
|
|
58
|
+
.map((c) => c.tag);
|
|
59
|
+
}
|
|
60
|
+
/** Picks the best well-formed tag from an Accept-Language header, see rankAcceptLanguage. */
|
|
61
|
+
export function pickAcceptLanguage(header) {
|
|
62
|
+
return rankAcceptLanguage(header)[0];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ordered locale preference of a page request: a valid `?lang=` first, then
|
|
66
|
+
* every Accept-Language tag by q. Canonicalized and deduped (case-insensitive),
|
|
67
|
+
* so a resolver can walk the list and take the first locale it has content for.
|
|
68
|
+
*/
|
|
69
|
+
export function rankPreferredLocales(options) {
|
|
70
|
+
const ranked = [];
|
|
71
|
+
if (options.queryLang != null && isValidLocaleTag(options.queryLang)) {
|
|
72
|
+
ranked.push(options.queryLang);
|
|
73
|
+
}
|
|
74
|
+
ranked.push(...rankAcceptLanguage(options.acceptLanguage ?? undefined));
|
|
75
|
+
return [...new Set(ranked.map(canonicalizeLocaleTag))];
|
|
59
76
|
}
|
|
60
77
|
/**
|
|
61
78
|
* Request-layer resolution: an explicit, validated X-Locale header wins;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createSystemUser } from "../engine/system-user.js";
|
|
|
12
12
|
import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
|
|
13
13
|
import { InternalError } from "../errors/index.js";
|
|
14
14
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
15
|
-
import {
|
|
15
|
+
import { createTenantFileContext } from "../files/file-handle.js";
|
|
16
16
|
import { createFallbackLogger } from "../logging/index.js";
|
|
17
17
|
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
18
18
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
@@ -903,9 +903,7 @@ export function createJobRunner(options) {
|
|
|
903
903
|
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
904
904
|
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
905
905
|
const fileResolver = context._fileProviderResolver;
|
|
906
|
-
const files = fileResolver
|
|
907
|
-
? createFileContext(() => fileResolver(tenantId), tenantId)
|
|
908
|
-
: context.files;
|
|
906
|
+
const files = fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files;
|
|
909
907
|
const jobSystemUser = createSystemUser(tenantId);
|
|
910
908
|
// Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
|
|
911
909
|
// must resolve for jobs the same way they do for write-handlers, or an
|
|
@@ -5,14 +5,14 @@ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTrans
|
|
|
5
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
6
|
import { mayOverrideTenant } from "../engine/cross-tenant.js";
|
|
7
7
|
import { createSystemUser } from "../engine/system-user.js";
|
|
8
|
-
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
8
|
+
import { declaredRateLimitOptions, isRateLimitDisabled, resolveAgentExposure, } from "../engine/types/index.js";
|
|
9
9
|
import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
|
|
10
10
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
11
11
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
12
12
|
import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
|
|
13
13
|
import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
|
|
14
14
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
15
|
-
import {
|
|
15
|
+
import { createTenantFileContext } from "../files/file-handle.js";
|
|
16
16
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
17
17
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
18
18
|
import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
@@ -459,9 +459,7 @@ readOnlyForTenant = false) {
|
|
|
459
459
|
// _fileProviderResolver when a file-provider plugin is mounted; falls back
|
|
460
460
|
// to a statically-injected context.files (tests).
|
|
461
461
|
const fileResolver = context._fileProviderResolver;
|
|
462
|
-
const files = fileResolver
|
|
463
|
-
? createFileContext(() => fileResolver(user.tenantId), user.tenantId)
|
|
464
|
-
: context.files;
|
|
462
|
+
const files = fileResolver ? createTenantFileContext(fileResolver, user.tenantId) : context.files;
|
|
465
463
|
// ctx.derivatives builds on ctx.files (needs a FileContext to read the
|
|
466
464
|
// original + write the variant) plus db (to look up the FileRef row) — so
|
|
467
465
|
// it can only be constructed exactly when files+db both resolved; falls
|
|
@@ -1109,16 +1107,28 @@ const DEFAULT_SYSTEM_SCOPE_RATE_LIMIT = {
|
|
|
1109
1107
|
limit: 600,
|
|
1110
1108
|
windowSeconds: 60,
|
|
1111
1109
|
};
|
|
1112
|
-
function
|
|
1113
|
-
|
|
1114
|
-
|
|
1110
|
+
function resolveEffectiveRateLimits(rateLimit, isSystemScope, user, resolver) {
|
|
1111
|
+
const declared = declaredRateLimitOptions(rateLimit);
|
|
1112
|
+
if (declared.length > 0)
|
|
1113
|
+
return declared;
|
|
1115
1114
|
if (!isSystemScope)
|
|
1116
|
-
return
|
|
1115
|
+
return [];
|
|
1117
1116
|
if (!resolver)
|
|
1118
|
-
return
|
|
1117
|
+
return [];
|
|
1119
1118
|
if (isSystemIdentity(user))
|
|
1120
|
-
return
|
|
1121
|
-
return DEFAULT_SYSTEM_SCOPE_RATE_LIMIT;
|
|
1119
|
+
return [];
|
|
1120
|
+
return [DEFAULT_SYSTEM_SCOPE_RATE_LIMIT];
|
|
1121
|
+
}
|
|
1122
|
+
// A single bucket keeps the plain enforce path; several go through one atomic
|
|
1123
|
+
// all-or-nothing call so a rejection by one bucket never charges the others.
|
|
1124
|
+
async function enforceBuckets(resolver, requests) {
|
|
1125
|
+
const [only, ...rest] = requests;
|
|
1126
|
+
// skip: no bucket to charge
|
|
1127
|
+
if (!only)
|
|
1128
|
+
return;
|
|
1129
|
+
await (rest.length === 0
|
|
1130
|
+
? resolver.enforce(only.bucket, only.config)
|
|
1131
|
+
: resolver.enforceAll(requests));
|
|
1122
1132
|
}
|
|
1123
1133
|
// L3 rate limit gate. Called by both query and write paths before
|
|
1124
1134
|
// access-check. Reasoning:
|
|
@@ -1137,42 +1147,48 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
|
|
|
1137
1147
|
// skip: handler explicitly opts out via rateLimit: { disabled: true }
|
|
1138
1148
|
if (isRateLimitDisabled(rateLimit))
|
|
1139
1149
|
return;
|
|
1140
|
-
const effective =
|
|
1150
|
+
const effective = resolveEffectiveRateLimits(rateLimit, isSystemScope, user, context.rateLimit);
|
|
1141
1151
|
// skip: no rateLimit declared and not systemScope (or resolved to no-op) — nothing to enforce
|
|
1142
|
-
if (
|
|
1152
|
+
if (effective.length === 0)
|
|
1143
1153
|
return;
|
|
1144
1154
|
const reqCtx = requestContext.get();
|
|
1145
|
-
const bucket = buildBucketKey(effective, {
|
|
1146
|
-
handlerName,
|
|
1147
|
-
user,
|
|
1148
|
-
ip: reqCtx?.ip,
|
|
1149
|
-
});
|
|
1150
|
-
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
1151
|
-
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
1152
|
-
if (bucket.kind === "skip")
|
|
1153
|
-
return;
|
|
1154
|
-
if (!context.rateLimit) {
|
|
1155
|
-
throw new InternalError({
|
|
1156
|
-
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
1155
|
// A nested dispatch re-hitting a bucket its entry dispatch already charged
|
|
1160
1156
|
// (e.g. a per:"ip" handler delegating via queryAs) must not cost a second token.
|
|
1161
1157
|
// The mark is set only after a successful enforce and includes the limit
|
|
1162
1158
|
// parameters: a denied or failed check never lets a later nested call through,
|
|
1163
1159
|
// and a nested handler with a stricter limit on the same bucket is still checked.
|
|
1164
1160
|
const charged = reqCtx?.chargedRateLimitBuckets;
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1161
|
+
const pending = [];
|
|
1162
|
+
for (const option of effective) {
|
|
1163
|
+
const bucket = buildBucketKey(option, { handlerName, user, ip: reqCtx?.ip });
|
|
1164
|
+
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
1165
|
+
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
1166
|
+
if (bucket.kind === "skip")
|
|
1167
|
+
continue;
|
|
1168
|
+
const chargedKey = `${bucket.key}|${option.limit}|${option.windowSeconds}|${option.cost}`;
|
|
1169
|
+
// skip: this exact bucket, limit and window was already charged by the entry dispatch
|
|
1170
|
+
if (charged?.has(chargedKey))
|
|
1171
|
+
continue;
|
|
1172
|
+
pending.push({
|
|
1173
|
+
request: {
|
|
1174
|
+
bucket: bucket.key,
|
|
1175
|
+
config: { limit: option.limit, windowSeconds: option.windowSeconds, cost: option.cost },
|
|
1176
|
+
},
|
|
1177
|
+
chargedKey,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
// skip: every bucket was skipped or already charged — nothing left to enforce
|
|
1181
|
+
if (pending.length === 0)
|
|
1168
1182
|
return;
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
windowSeconds: effective.windowSeconds,
|
|
1173
|
-
cost: effective.cost,
|
|
1183
|
+
if (!context.rateLimit) {
|
|
1184
|
+
throw new InternalError({
|
|
1185
|
+
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
1174
1186
|
});
|
|
1175
|
-
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
await enforceBuckets(context.rateLimit, pending.map(({ request }) => request));
|
|
1190
|
+
for (const { chargedKey } of pending)
|
|
1191
|
+
charged?.add(chargedKey);
|
|
1176
1192
|
}
|
|
1177
1193
|
catch (error) {
|
|
1178
1194
|
// Backend failure (Redis down): fail closed as 503, same as L1/L2.
|
|
@@ -1205,6 +1221,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1205
1221
|
message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
|
|
1206
1222
|
});
|
|
1207
1223
|
}
|
|
1224
|
+
const requests = [];
|
|
1208
1225
|
for (const { per, limit, windowSeconds, cost, normalize } of limits) {
|
|
1209
1226
|
const value = data[per.payloadField];
|
|
1210
1227
|
// skip: optional field not sent — nothing to bucket on
|
|
@@ -1215,9 +1232,10 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1215
1232
|
message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
|
|
1216
1233
|
});
|
|
1217
1234
|
}
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1235
|
+
const bucket = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
|
|
1236
|
+
requests.push({ bucket, config: { limit, windowSeconds, cost } });
|
|
1220
1237
|
}
|
|
1238
|
+
await enforceBuckets(context.rateLimit, requests);
|
|
1221
1239
|
}
|
|
1222
1240
|
// Build the per-hook context every auth-claims invocation gets. Claims
|
|
1223
1241
|
// hooks run OUTSIDE any request transaction (login is itself the root
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.ts";
|
|
2
2
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.ts";
|
|
3
3
|
export { normalizePayloadBucketValue } from "../rate-limit/bucket.ts";
|
|
4
|
+
export { createUnlimitedRateLimitResolver } from "../rate-limit/unlimited-resolver.ts";
|
|
4
5
|
export type { AppendDomainEventCoreDeps } from "./append-event-core.ts";
|
|
5
6
|
export { appendDomainEventCore } from "./append-event-core.ts";
|
|
6
7
|
export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.ts";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
2
2
|
export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
|
|
3
|
+
export { createUnlimitedRateLimitResolver } from "../rate-limit/unlimited-resolver.js";
|
|
3
4
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
4
5
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
5
6
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.ts";
|
|
2
2
|
export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.ts";
|
|
3
|
-
export { createRateLimitResolver, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
|
|
3
|
+
export { createRateLimitResolver, type RateLimitBucketRequest, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
|
|
4
|
+
export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.ts";
|
package/dist/rate-limit/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
|
|
2
2
|
export { authEndpointRateLimit, globalIpRateLimit, httpRouteRateLimit, } from "./middleware.js";
|
|
3
3
|
export { createRateLimitResolver, } from "./resolver.js";
|
|
4
|
+
export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RateLimitResolver } from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
2
2
|
import type { Redis } from "ioredis";
|
|
3
|
-
export type { RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
3
|
+
export type { RateLimitBucketRequest, RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
4
4
|
export type RateLimitResolverOptions = {
|
|
5
5
|
readonly redis: Redis;
|
|
6
6
|
readonly keyPrefix?: string;
|