@cosmicdrift/kumiko-samples 0.364.0 → 0.365.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +13 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +7 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +12 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
- package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +65 -118
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
- package/packages/bundled-features/src/template-resolver/changes.json +81 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +6 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
- package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +9 -3
- package/packages/bundled-features/src/tier-engine/changes.json +12 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
9
9
|
import {
|
|
10
|
+
type CacheSyncBus,
|
|
10
11
|
type ClosableCacheSyncBus,
|
|
11
12
|
createDefaultCacheSyncBus,
|
|
12
13
|
} from "@cosmicdrift/kumiko-framework/redis";
|
|
@@ -40,9 +41,11 @@ export type ToggleSyncSignal = {
|
|
|
40
41
|
// per-instance behavior for the only process there is.
|
|
41
42
|
export class GlobalFeatureToggleRuntime {
|
|
42
43
|
private snapshot = new Map<string, boolean>();
|
|
43
|
-
//
|
|
44
|
-
// started before
|
|
45
|
-
private
|
|
44
|
+
// One buffer per requested load, open from the call until its SELECT finished: the SELECT
|
|
45
|
+
// may have started before a flip committed, so its rows can be older than the flips collected.
|
|
46
|
+
private readonly pendingFlipBuffers = new Set<Map<string, boolean>>();
|
|
47
|
+
// Loads run one at a time so the older rows of one cannot overwrite the result of another.
|
|
48
|
+
private loadInFlight: Promise<void> = Promise.resolve();
|
|
46
49
|
|
|
47
50
|
constructor(
|
|
48
51
|
private readonly db: DbConnection,
|
|
@@ -69,10 +72,16 @@ export class GlobalFeatureToggleRuntime {
|
|
|
69
72
|
await this.ownedBus?.close();
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
type Row = { featureName: string; enabled: boolean };
|
|
75
|
+
initialize(): Promise<void> {
|
|
74
76
|
const pendingFlips = new Map<string, boolean>();
|
|
75
|
-
this.
|
|
77
|
+
this.pendingFlipBuffers.add(pendingFlips);
|
|
78
|
+
const load = this.loadInFlight.then(() => this.loadSnapshot(pendingFlips));
|
|
79
|
+
this.loadInFlight = load.catch(() => undefined);
|
|
80
|
+
return load;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async loadSnapshot(pendingFlips: Map<string, boolean>): Promise<void> {
|
|
84
|
+
type Row = { featureName: string; enabled: boolean };
|
|
76
85
|
try {
|
|
77
86
|
const rows = await selectMany<Row>(this.db, globalFeatureStateTable);
|
|
78
87
|
this.snapshot = new Map([
|
|
@@ -80,7 +89,7 @@ export class GlobalFeatureToggleRuntime {
|
|
|
80
89
|
...pendingFlips,
|
|
81
90
|
]);
|
|
82
91
|
} finally {
|
|
83
|
-
this.
|
|
92
|
+
this.pendingFlipBuffers.delete(pendingFlips);
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
95
|
|
|
@@ -98,7 +107,7 @@ export class GlobalFeatureToggleRuntime {
|
|
|
98
107
|
// lock. Kept alongside refresh() so both options are explicit.
|
|
99
108
|
apply(featureName: string, enabled: boolean): void {
|
|
100
109
|
this.snapshot.set(featureName, enabled);
|
|
101
|
-
this.
|
|
110
|
+
for (const pendingFlips of this.pendingFlipBuffers) pendingFlips.set(featureName, enabled);
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
// Called by the toggle-cache-sync MSP handler (feature.ts) — the one
|
|
@@ -144,15 +153,20 @@ export class GlobalFeatureToggleRuntime {
|
|
|
144
153
|
|
|
145
154
|
// Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
|
|
146
155
|
// set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
|
|
147
|
-
// syncSignal the
|
|
148
|
-
// REDIS_URL is set, process-local otherwise. close() releases
|
|
156
|
+
// syncSignal the signal runs on the given cacheSyncBus or, failing that, on a bus the runtime
|
|
157
|
+
// creates itself (Redis-backed when REDIS_URL is set, process-local otherwise). close() releases
|
|
158
|
+
// only a bus the runtime created.
|
|
149
159
|
export async function createFeatureToggleRuntime(
|
|
150
160
|
db: DbConnection,
|
|
151
161
|
registry: Registry,
|
|
152
162
|
syncSignal?: ToggleSyncSignal,
|
|
163
|
+
// The server's own bus (the one handed to the dispatcher): reusing it avoids a second set of
|
|
164
|
+
// Redis connections that nothing closes at shutdown.
|
|
165
|
+
cacheSyncBus?: CacheSyncBus,
|
|
153
166
|
): Promise<GlobalFeatureToggleRuntime> {
|
|
154
|
-
const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
|
|
155
|
-
const
|
|
167
|
+
const ownedBus = syncSignal || cacheSyncBus ? undefined : createDefaultCacheSyncBus();
|
|
168
|
+
const sharedBus = cacheSyncBus ?? ownedBus;
|
|
169
|
+
const signal = syncSignal ?? (sharedBus ? createCacheSyncToggleSignal(sharedBus) : undefined);
|
|
156
170
|
const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
|
|
157
171
|
try {
|
|
158
172
|
await runtime.initialize();
|
|
@@ -27,8 +27,10 @@ import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js"
|
|
|
27
27
|
const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON =
|
|
28
28
|
"iterates every tenant and checks fileRef owners per tenant";
|
|
29
29
|
|
|
30
|
+
export const FILES_TENANT_DATA_FEATURE = "files-tenant-data" as const;
|
|
31
|
+
|
|
30
32
|
export function createFilesTenantDataFeature(): FeatureDefinition {
|
|
31
|
-
return defineFeature(
|
|
33
|
+
return defineFeature(FILES_TENANT_DATA_FEATURE, (r) => {
|
|
32
34
|
r.describe(
|
|
33
35
|
"GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.",
|
|
34
36
|
);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
instantToTimestamptzParam,
|
|
3
|
+
unsafeReadRetrying,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
5
|
+
import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
|
|
3
6
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
7
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
5
8
|
|
|
@@ -68,7 +71,13 @@ export async function filterOwnedFileRefs(
|
|
|
68
71
|
WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
|
|
69
72
|
AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
|
|
70
73
|
AND ($5::boolean OR "inserted_at" > $4::timestamptz)`,
|
|
71
|
-
[
|
|
74
|
+
[
|
|
75
|
+
tenantId,
|
|
76
|
+
ownerId,
|
|
77
|
+
toPostgresArrayLiteral(candidateKeys),
|
|
78
|
+
instantToTimestamptzParam(draftInsertedAt),
|
|
79
|
+
isCreateMode,
|
|
80
|
+
],
|
|
72
81
|
);
|
|
73
82
|
return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
|
|
74
83
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Finished tenant run reads its timestamp under the lock",
|
|
6
|
+
"detail": "The run that finishes last in lock order now carries the latest finishedAt, so a stale timestamp can no longer make the newest run the row that gets pruned."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Older migration notes that say \"kumiko migrate generate\" mean kumiko-schema generate",
|
|
12
|
+
"detail": "Migration notes of the delivery, jobs, metrics and sessions features name a command that does not exist, `kumiko migrate generate`. Run `kumiko-schema generate <name>` and then `kumiko-schema apply` instead. The published notes stay as they were."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.364.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -341,13 +341,16 @@ async function recordTenantRunOutcome(
|
|
|
341
341
|
TENANT_JOB_RUN_LOCK_NAMESPACE,
|
|
342
342
|
JSON.stringify([key.tenantId, key.jobName, key.subject]),
|
|
343
343
|
);
|
|
344
|
+
// Read under the lock: the run that finishes last in lock order must also carry the
|
|
345
|
+
// latest finishedAt, otherwise a stale timestamp makes it the row that gets pruned.
|
|
346
|
+
const finishedAt = Temporal.Now.instant();
|
|
344
347
|
await upsertOnConflict(
|
|
345
348
|
tx,
|
|
346
349
|
tenantJobRunsTable,
|
|
347
|
-
{ ...key, bullJobId, status: result, finishedAt
|
|
350
|
+
{ ...key, bullJobId, status: result, finishedAt, updatedAt: finishedAt },
|
|
348
351
|
{
|
|
349
352
|
conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
|
|
350
|
-
update: { status: result, finishedAt
|
|
353
|
+
update: { status: result, finishedAt, updatedAt: finishedAt },
|
|
351
354
|
},
|
|
352
355
|
);
|
|
353
356
|
await deleteMany(tx, tenantJobRunsTable, {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Metric integer filters reject fractions and out-of-range values; feature-name constants exported",
|
|
6
|
+
"detail": "Filters on smallint, integer and bigint columns now fail validation for decimals and values outside the column range instead of a Postgres cast error. FILES_FEATURE and FILES_TENANT_DATA_FEATURE are exported and reused by compose-features."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.349.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -7,26 +7,34 @@ export const RESERVED_PAYLOAD_KEYS: readonly string[] = ["range", "timeZone", "t
|
|
|
7
7
|
|
|
8
8
|
// Dashboard select options always send strings.
|
|
9
9
|
const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
|
|
10
|
+
const INTEGER_STRING = /^-?\d+$/;
|
|
10
11
|
const MAX_FILTER_STRING_LENGTH = 200;
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
const INTEGER_RANGES = {
|
|
13
|
+
smallint: { min: -32_768, max: 32_767 },
|
|
14
|
+
integer: { min: -2_147_483_648, max: 2_147_483_647 },
|
|
15
|
+
// bigint is bound as a JS number, so only the exactly representable range is accepted.
|
|
16
|
+
bigint: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
|
|
17
|
+
} as const;
|
|
18
|
+
const PG_INTEGER_KINDS: Readonly<Record<string, keyof typeof INTEGER_RANGES>> = {
|
|
19
|
+
smallint: "smallint",
|
|
20
|
+
int2: "smallint",
|
|
21
|
+
integer: "integer",
|
|
22
|
+
int4: "integer",
|
|
23
|
+
serial: "integer",
|
|
24
|
+
bigint: "bigint",
|
|
25
|
+
bigserial: "bigint",
|
|
26
|
+
};
|
|
21
27
|
|
|
22
|
-
export type FilterValueKind = "uuid" | "number" | "string";
|
|
28
|
+
export type FilterValueKind = "uuid" | "number" | "string" | keyof typeof INTEGER_RANGES;
|
|
23
29
|
|
|
24
30
|
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
25
31
|
export function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined {
|
|
26
32
|
if (pgType === undefined) return undefined;
|
|
27
33
|
if (pgType === "uuid") return "uuid";
|
|
28
34
|
if (pgType === "text") return "string";
|
|
29
|
-
|
|
35
|
+
const integerKind = PG_INTEGER_KINDS[pgType];
|
|
36
|
+
if (integerKind !== undefined) return integerKind;
|
|
37
|
+
if (pgType === "double precision" || pgType.startsWith("numeric")) return "number";
|
|
30
38
|
return undefined;
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -44,8 +52,16 @@ export function metricFilterEntries(metric: MetricDefinition): readonly MetricFi
|
|
|
44
52
|
);
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
function integerValueSchema(range: { readonly min: number; readonly max: number }): z.ZodType {
|
|
56
|
+
const bounded = z.number().int().min(range.min).max(range.max);
|
|
57
|
+
return z.union([bounded, z.string().regex(INTEGER_STRING).transform(Number).pipe(bounded)]);
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
function filterValueSchema(kind: FilterValueKind): z.ZodType {
|
|
48
61
|
if (kind === "uuid") return z.uuid();
|
|
62
|
+
if (kind === "smallint" || kind === "integer" || kind === "bigint") {
|
|
63
|
+
return integerValueSchema(INTEGER_RANGES[kind]);
|
|
64
|
+
}
|
|
49
65
|
if (kind === "number")
|
|
50
66
|
return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
|
|
51
67
|
return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "revokeAllPatTokensForUser detects begin() the same way as the transaction helpers",
|
|
6
|
+
"detail": "The transaction probe looked for begin() directly on the handle, so a pool wrapped by a legacy Drizzle connection fell back to a non-atomic update plus append. It now uses runnerCanBeginTransaction from bun-db, which unwraps the client first."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.347.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
runnerCanBeginTransaction,
|
|
3
|
+
transaction,
|
|
4
|
+
updateMany,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
6
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
7
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
8
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
9
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
10
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
7
|
-
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
8
11
|
import {
|
|
9
12
|
PAT_REVOKED_AGGREGATE_TYPE,
|
|
10
13
|
PAT_REVOKED_EVENT_QN,
|
|
@@ -12,11 +15,6 @@ import {
|
|
|
12
15
|
} from "./pat-revoked-event.js";
|
|
13
16
|
import { apiTokenTable } from "./schema/api-token.js";
|
|
14
17
|
|
|
15
|
-
// A pool has begin(); a tx/savepoint handle does not and is already atomic.
|
|
16
|
-
function canBeginTransaction(db: DbRunner): boolean {
|
|
17
|
-
return typeof propertyOf(db, "begin") === "function";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
18
|
// Cross-tenant revoke: password-change and MFA-enable/disable are account-
|
|
21
19
|
// level security events, not scoped to one tenant. Mirrors sessions'
|
|
22
20
|
// sessionMassRevoker (session-callbacks.ts) which passes the boot-time
|
|
@@ -24,7 +22,8 @@ function canBeginTransaction(db: DbRunner): boolean {
|
|
|
24
22
|
export async function revokeAllPatTokensForUser(db: DbRunner, userId: string): Promise<number> {
|
|
25
23
|
// Update and event commit together: otherwise a failed append leaves the
|
|
26
24
|
// tokens revoked with no event, and a retry matches no live row.
|
|
27
|
-
|
|
25
|
+
// A pool has begin(); a tx/savepoint handle does not and is already atomic.
|
|
26
|
+
return runnerCanBeginTransaction(db)
|
|
28
27
|
? transaction(db, (tx) => revokeAndAppend(tx, userId))
|
|
29
28
|
: revokeAndAppend(db, userId);
|
|
30
29
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "secrets delete no longer requires a declared key",
|
|
6
|
+
"detail": "secrets:write:delete used to answer 404 unknownKey for a key that no feature declares, so a row stored before the declaration gate existed could never be removed. Delete now skips the declaration check; writeRoles of a declared key or namespace still apply, and set keeps the gate."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.364.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -18,7 +18,9 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
|
|
|
18
18
|
access,
|
|
19
19
|
handler: async (event, ctx) => {
|
|
20
20
|
const secrets = requireSecretsContext(ctx, "secrets:write:delete");
|
|
21
|
-
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key
|
|
21
|
+
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key, {
|
|
22
|
+
allowUndeclared: true,
|
|
23
|
+
});
|
|
22
24
|
if (!gate.ok) return gate.failure;
|
|
23
25
|
const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
|
|
24
26
|
deletedBy: event.user.id,
|
|
@@ -37,15 +37,21 @@ function namespaceAcceptsKey(namespace: SecretNamespaceDefinition, key: string):
|
|
|
37
37
|
// Only keys declared via r.secret() or under a declared r.secretNamespace()
|
|
38
38
|
// are writable through the API, so a tenant admin cannot plant arbitrary rows
|
|
39
39
|
// next to the feature-owned ones. writeRoles narrows the handler access
|
|
40
|
-
// further for a key or a whole namespace.
|
|
40
|
+
// further for a key or a whole namespace. Delete passes allowUndeclared: it
|
|
41
|
+
// creates nothing, and legacy rows under since-undeclared keys must stay removable.
|
|
41
42
|
export function checkSecretKeyWrite(
|
|
42
43
|
registry: Registry,
|
|
43
44
|
userRoles: readonly string[],
|
|
44
45
|
key: string,
|
|
46
|
+
options: { readonly allowUndeclared?: boolean } = {},
|
|
45
47
|
): SecretKeyWriteCheck {
|
|
46
48
|
const keyDef = registry.getSecretKey(key);
|
|
47
49
|
const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
|
|
48
|
-
if (
|
|
50
|
+
if (
|
|
51
|
+
!keyDef &&
|
|
52
|
+
!(namespace && namespaceAcceptsKey(namespace, key)) &&
|
|
53
|
+
options.allowUndeclared !== true
|
|
54
|
+
) {
|
|
49
55
|
return unknownKeyFailure(key);
|
|
50
56
|
}
|
|
51
57
|
const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;
|
|
@@ -40,6 +40,14 @@ export {
|
|
|
40
40
|
} from "./redact.js";
|
|
41
41
|
export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
|
|
42
42
|
export { requireForTenant } from "./require-for-tenant.js";
|
|
43
|
+
export {
|
|
44
|
+
extensionForResourceContentType,
|
|
45
|
+
isResourceImageContentType,
|
|
46
|
+
isResourceImageFormat,
|
|
47
|
+
RESOURCE_IMAGE_FORMATS,
|
|
48
|
+
type ResourceImageContentType,
|
|
49
|
+
type ResourceImageFormat,
|
|
50
|
+
} from "./resource-image-formats.js";
|
|
43
51
|
export {
|
|
44
52
|
type RowBoundGrantResult,
|
|
45
53
|
redeemRowBoundGrant,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Single source for the raster formats a template resource image may have: the normalizer, the
|
|
2
|
+
// stored file extension, the data-URI embedding and the inline mail attachment name all derive
|
|
3
|
+
// from it, so a new format cannot be accepted in one place and dropped in another.
|
|
4
|
+
export const RESOURCE_IMAGE_FORMATS = {
|
|
5
|
+
png: { contentType: "image/png", extension: "png" },
|
|
6
|
+
jpeg: { contentType: "image/jpeg", extension: "jpg" },
|
|
7
|
+
gif: { contentType: "image/gif", extension: "gif" },
|
|
8
|
+
webp: { contentType: "image/webp", extension: "webp" },
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type ResourceImageFormat = keyof typeof RESOURCE_IMAGE_FORMATS;
|
|
12
|
+
export type ResourceImageContentType =
|
|
13
|
+
(typeof RESOURCE_IMAGE_FORMATS)[ResourceImageFormat]["contentType"];
|
|
14
|
+
|
|
15
|
+
export function isResourceImageFormat(format: string | undefined): format is ResourceImageFormat {
|
|
16
|
+
return format !== undefined && Object.hasOwn(RESOURCE_IMAGE_FORMATS, format);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatForContentType(contentType: string) {
|
|
20
|
+
return Object.values(RESOURCE_IMAGE_FORMATS).find((entry) => entry.contentType === contentType);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isResourceImageContentType(
|
|
24
|
+
contentType: string,
|
|
25
|
+
): contentType is ResourceImageContentType {
|
|
26
|
+
return formatForContentType(contentType) !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extensionForResourceContentType(contentType: string): string | undefined {
|
|
30
|
+
return formatForContentType(contentType)?.extension;
|
|
31
|
+
}
|
|
@@ -5,22 +5,21 @@
|
|
|
5
5
|
// to be an OOM primitive for a single request.
|
|
6
6
|
export const MAX_INPUT_PIXELS = 100_000_000;
|
|
7
7
|
// sequentialRead trades random pixel access for lower peak memory on
|
|
8
|
-
// streamed formats
|
|
9
|
-
//
|
|
10
|
-
// against a single already-buffered region per call).
|
|
8
|
+
// streamed formats. Shared by every sharp caller since none of them need
|
|
9
|
+
// non-sequential access to the original bytes.
|
|
11
10
|
export const SHARP_INPUT_OPTIONS = {
|
|
12
11
|
limitInputPixels: MAX_INPUT_PIXELS,
|
|
13
12
|
sequentialRead: true,
|
|
14
13
|
} as const;
|
|
15
14
|
|
|
16
|
-
// Shared trust boundary for every image byte source
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
15
|
+
// Shared trust boundary for every image byte source decoded through sharp:
|
|
16
|
+
// uploads, overlay layers and template resources alike. A base64 string in a
|
|
17
|
+
// field declaration isn't more trustworthy than an upload just because it
|
|
18
|
+
// came from app code.
|
|
20
19
|
export function assertNotSvg(format: string | undefined): void {
|
|
21
20
|
if (format === "svg") {
|
|
22
21
|
throw new Error(
|
|
23
|
-
"
|
|
22
|
+
"SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this code doesn't need to cross.",
|
|
24
23
|
);
|
|
25
24
|
}
|
|
26
25
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "step-dispatcher delivers each dispatch as a retried job, so a slow receiver no longer delays other tenants",
|
|
6
|
+
"detail": "The step-dispatcher projection now only enqueues one `step-dispatcher:job:deliver` job per dispatch request, and the job performs the webhook or mail delivery. Redis holds only the dispatch stream id and the tenant, never the request payload. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome event and the payload key erase follow after the final attempt. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded at once. At most 2 deliveries per tenant run at the same time, so one tenant's slow receivers cannot hold up the others. This corrects the 0.346.0 note that a hanging receiver no longer stalls the dispatcher for every tenant: the shared consumer still delivered one request at a time.",
|
|
7
|
+
"migration": "step-dispatcher now needs a job runner (Redis) and a worker consuming the default worker lane. Without a job runner the projection throws instead of delivering. Test stacks must pass `jobs` to setupTestStack and call `drainJobs()` after the event dispatcher ran. `r.job` accepts `maxPerTenant: { limit, onLimit: \"defer\", deferMs? }`; a plain number still drops excess enqueues. A `webhook.send` request that gets a retryable status (5xx, 408, 429) is now sent up to 4 times under the same Idempotency-Key. Worker pods must know the deliver job before the projection enqueues it, so deploy worker and API together or the worker first. A dev server only consumes jobs when the app mounts `createJobsFeature()`."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.347.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -21,7 +28,7 @@
|
|
|
21
28
|
"version": "0.346.0",
|
|
22
29
|
"type": "fix",
|
|
23
30
|
"title": "Webhook requests time out after 10 seconds instead of blocking the dispatcher",
|
|
24
|
-
"detail": "Webhook requests time out after 10 seconds\nA hanging receiver now ends as `step.dispatch-failed`
|
|
31
|
+
"detail": "Webhook requests time out after 10 seconds\nA hanging receiver now ends as `step.dispatch-failed` after the timeout instead of waiting indefinitely. Until the step-dispatcher delivered through jobs, the shared consumer still processed requests one at a time, so a slow receiver of one tenant delayed the others."
|
|
25
32
|
},
|
|
26
33
|
{
|
|
27
34
|
"version": "0.346.0",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export const STEP_DISPATCHER_FEATURE = "step-dispatcher";
|
|
4
|
+
|
|
5
|
+
export const StepDispatcherJobNames = {
|
|
6
|
+
deliver: "deliver",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export const StepDispatcherJobs = {
|
|
10
|
+
deliver: qn(STEP_DISPATCHER_FEATURE, QnTypes.job, StepDispatcherJobNames.deliver),
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
// Stable per dispatch stream: BullMQ ignores a second add() with the same id,
|
|
14
|
+
// so an MSP redelivery cannot enqueue a second delivery.
|
|
15
|
+
export function deliverJobId(aggregateId: string): string {
|
|
16
|
+
return `step-dispatch-${aggregateId}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Per-tenant cap on simultaneous deliveries, so one tenant's slow receivers
|
|
20
|
+
// cannot occupy every worker slot.
|
|
21
|
+
export const DELIVER_MAX_PER_TENANT = 2;
|
|
22
|
+
export const DELIVER_RETRIES = 3;
|