@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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/package.json +8 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +164 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +118 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +17 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +130 -40
- package/src/changes.json +121 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/schema-cli.ts +5 -1
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
- package/src/upgrade-cli.ts +58 -18
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { Queue } from "bullmq";
|
|
3
|
+
import { Redis } from "ioredis";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import { requestContext } from "../../api/request-context";
|
|
5
6
|
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
TenantId,
|
|
14
15
|
} from "../../engine/types";
|
|
15
16
|
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
17
|
+
import { RedisKeys } from "../../pipeline/redis-keys";
|
|
16
18
|
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
17
19
|
import { sleep, waitFor } from "../../testing";
|
|
18
20
|
import {
|
|
@@ -498,10 +500,110 @@ describe("concurrency: sequential", () => {
|
|
|
498
500
|
expect(delta12).toBeGreaterThanOrEqual(250);
|
|
499
501
|
expect(delta23).toBeGreaterThanOrEqual(250);
|
|
500
502
|
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
503
|
+
// NOT dispatch order: "sequential" is mutual exclusion, not a FIFO
|
|
504
|
+
// queue. A re-enqueued lock loser goes to the back of its
|
|
505
|
+
// queue, so it can still win the next race. What's guaranteed, and
|
|
506
|
+
// already asserted above via delta12/delta23, is that runs never
|
|
507
|
+
// overlap. Assert the complete set instead of a specific order.
|
|
508
|
+
expect(entries.map((e) => e.payload)).toEqual(
|
|
509
|
+
expect.arrayContaining([{ n: 1 }, { n: 2 }, { n: 3 }]),
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("runners with distinct queueNamePrefix do not share the sequential lock", async () => {
|
|
515
|
+
clearLog();
|
|
516
|
+
// Regression guard: the lock key used to omit queueNamePrefix, so two
|
|
517
|
+
// isolated runners sharing a Redis collided on the same lock.
|
|
518
|
+
const prefixA = `kumiko-test-lockscope-a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
519
|
+
const prefixB = `kumiko-test-lockscope-b-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
520
|
+
const runnerA = createJobRunner({
|
|
521
|
+
registry: createRegistry([testFeature]),
|
|
522
|
+
context: {},
|
|
523
|
+
redisUrl,
|
|
524
|
+
consumerLane: "worker",
|
|
525
|
+
queueNamePrefix: prefixA,
|
|
526
|
+
});
|
|
527
|
+
const runnerB = createJobRunner({
|
|
528
|
+
registry: createRegistry([testFeature]),
|
|
529
|
+
context: {},
|
|
530
|
+
redisUrl,
|
|
531
|
+
consumerLane: "worker",
|
|
532
|
+
queueNamePrefix: prefixB,
|
|
533
|
+
});
|
|
534
|
+
// Own client, no keyPrefix — testRedis.redis auto-prefixes SET/DEL and
|
|
535
|
+
// would write a different key than the runner's own lock connection.
|
|
536
|
+
const lockKeyA = `${RedisKeys.lock}seq:${prefixA}:worker:test:job:sequential-job`;
|
|
537
|
+
const rawRedis = new Redis(redisUrl);
|
|
538
|
+
rawRedis.on("error", () => {});
|
|
539
|
+
const queueA = new Queue(`${prefixA}-worker`, {
|
|
540
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
504
541
|
});
|
|
542
|
+
// A post-close 'error' here is otherwise unhandled and bun:test
|
|
543
|
+
// attributes it to whichever test runs next (fw#1805).
|
|
544
|
+
queueA.on("error", () => {});
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
await rawRedis.set(lockKeyA, "held-by-test", "EX", 30, "NX");
|
|
548
|
+
|
|
549
|
+
await runnerA.start();
|
|
550
|
+
await runnerB.start();
|
|
551
|
+
|
|
552
|
+
await runnerA.dispatch("test:job:sequential-job", { n: 100 });
|
|
553
|
+
|
|
554
|
+
// A's handler returns successfully on a lock loss (re-enqueue, not a
|
|
555
|
+
// throw) — BullMQ records the dropped attempt as completed even
|
|
556
|
+
// though the job body never ran. Filtered by name+payload so unrelated
|
|
557
|
+
// boot/cron jobs on the same worker queue don't false-positive this.
|
|
558
|
+
await waitFor(
|
|
559
|
+
async () => {
|
|
560
|
+
const completed = await queueA.getCompleted();
|
|
561
|
+
const lostAttempts = completed.filter(
|
|
562
|
+
(job) => job.name === "test:job:sequential-job" && job.data.n === 100,
|
|
563
|
+
);
|
|
564
|
+
expect(lostAttempts.length).toBeGreaterThanOrEqual(1);
|
|
565
|
+
},
|
|
566
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
567
|
+
);
|
|
568
|
+
expect(
|
|
569
|
+
jobLog.some((e) => e.name === "test:job:sequential-job" && e.payload["n"] === 100),
|
|
570
|
+
).toBe(false);
|
|
571
|
+
|
|
572
|
+
// B shares no lock scope with the externally held A-key, so it must
|
|
573
|
+
// run despite A being blocked.
|
|
574
|
+
await runnerB.dispatch("test:job:sequential-job", { n: 200 });
|
|
575
|
+
await waitFor(
|
|
576
|
+
() => {
|
|
577
|
+
const entries = jobLog.filter(
|
|
578
|
+
(e) => e.name === "test:job:sequential-job" && e.payload["n"] === 200,
|
|
579
|
+
);
|
|
580
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
581
|
+
},
|
|
582
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
// Releasing the externally held key unblocks A's own dispatch.
|
|
586
|
+
await rawRedis.del(lockKeyA);
|
|
587
|
+
await waitFor(
|
|
588
|
+
() => {
|
|
589
|
+
const entries = jobLog.filter(
|
|
590
|
+
(e) => e.name === "test:job:sequential-job" && e.payload["n"] === 100,
|
|
591
|
+
);
|
|
592
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
593
|
+
},
|
|
594
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
595
|
+
);
|
|
596
|
+
} finally {
|
|
597
|
+
await runnerA.stop();
|
|
598
|
+
await runnerB.stop();
|
|
599
|
+
await queueA.close();
|
|
600
|
+
await rawRedis.del(lockKeyA);
|
|
601
|
+
rawRedis.disconnect();
|
|
602
|
+
const keysA = await testRedis.redis.keys(`bull:${prefixA}-worker:*`);
|
|
603
|
+
if (keysA.length > 0) await testRedis.redis.del(...keysA);
|
|
604
|
+
const keysB = await testRedis.redis.keys(`bull:${prefixB}-worker:*`);
|
|
605
|
+
if (keysB.length > 0) await testRedis.redis.del(...keysB);
|
|
606
|
+
}
|
|
505
607
|
});
|
|
506
608
|
|
|
507
609
|
test("lock is released even when the handler throws", async () => {
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -550,7 +550,12 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
550
550
|
log("lock redis connection error", { error: err.message });
|
|
551
551
|
});
|
|
552
552
|
const lockScope = consumerLane ?? "enqueue";
|
|
553
|
-
|
|
553
|
+
// Scoped by queueNamePrefix like the queues themselves, so runners
|
|
554
|
+
// isolated by prefix on a shared Redis don't contend for the same lock.
|
|
555
|
+
sequentialLock = createDistributedLock(
|
|
556
|
+
lockRedis,
|
|
557
|
+
`${RedisKeys.lock}seq:${queueNamePrefix}:${lockScope}:`,
|
|
558
|
+
);
|
|
554
559
|
}
|
|
555
560
|
// Default lock-TTL for sequential jobs that didn't declare a timeout.
|
|
556
561
|
// 5 minutes matches BullMQ's default stalledInterval — long enough for
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DerivativesContext } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
2
|
import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
|
|
3
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-types/secrets-types";
|
|
3
4
|
import type { DbRunner } from "../db/connection";
|
|
4
5
|
import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
|
|
5
6
|
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
|
|
@@ -32,6 +33,8 @@ export type MultiStreamApplyContextDeps = {
|
|
|
32
33
|
readonly files?: FileContext;
|
|
33
34
|
// Same DerivativesContext the outer AppContext carries — mirrors `files`.
|
|
34
35
|
readonly derivatives?: DerivativesContext;
|
|
36
|
+
// Same SecretsContext the outer AppContext carries, mirrors `files`.
|
|
37
|
+
readonly secrets?: SecretsContext;
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export function createMultiStreamApplyContext(
|
|
@@ -41,6 +44,7 @@ export function createMultiStreamApplyContext(
|
|
|
41
44
|
registry: deps.registry,
|
|
42
45
|
...(deps.files ? { files: deps.files } : {}),
|
|
43
46
|
...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
|
|
47
|
+
...(deps.secrets ? { secrets: deps.secrets } : {}),
|
|
44
48
|
appendEvent: (async (args: AppendEventArgs) => {
|
|
45
49
|
await appendDomainEventCore(
|
|
46
50
|
{
|
|
@@ -28,7 +28,16 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
28
28
|
const app = new Hono();
|
|
29
29
|
app.use(
|
|
30
30
|
"/api/*",
|
|
31
|
-
|
|
31
|
+
// trustedProxyHops: 1 — the default hops=0 ignores x-forwarded-for
|
|
32
|
+
// outright, which would collapse the bucket
|
|
33
|
+
// assertion below onto "l1:unknown" instead of the literal IP.
|
|
34
|
+
globalIpRateLimit({
|
|
35
|
+
resolver,
|
|
36
|
+
limit: 3,
|
|
37
|
+
windowSeconds: 60,
|
|
38
|
+
trustedProxyHops: 1,
|
|
39
|
+
onFailClosed: () => {},
|
|
40
|
+
}),
|
|
32
41
|
);
|
|
33
42
|
app.get("/api/probe", (c) => c.text("ok"));
|
|
34
43
|
|
|
@@ -56,7 +65,13 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
56
65
|
const app = new Hono();
|
|
57
66
|
app.use(
|
|
58
67
|
"/api/*",
|
|
59
|
-
globalIpRateLimit({
|
|
68
|
+
globalIpRateLimit({
|
|
69
|
+
resolver,
|
|
70
|
+
limit: 2,
|
|
71
|
+
windowSeconds: 60,
|
|
72
|
+
trustedProxyHops: 1,
|
|
73
|
+
onFailClosed: () => {},
|
|
74
|
+
}),
|
|
60
75
|
);
|
|
61
76
|
app.get("/api/probe", (c) => c.text("ok"));
|
|
62
77
|
|
|
@@ -73,7 +88,11 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
73
88
|
expect(otherIp.status).toBe(200);
|
|
74
89
|
});
|
|
75
90
|
|
|
76
|
-
|
|
91
|
+
// The built-in resolver never returns undefined — no x-forwarded-for (or
|
|
92
|
+
// an untrusted one at hops=0) falls back to a fixed "unknown" bucket
|
|
93
|
+
// instead of skipping the middleware: skipping would fail open, letting
|
|
94
|
+
// an attacker omit/rotate the header to dodge L1 entirely.
|
|
95
|
+
test("no x-forwarded-for: still enforced via the shared 'unknown' bucket, never skipped", async () => {
|
|
77
96
|
const app = new Hono();
|
|
78
97
|
app.use(
|
|
79
98
|
"/api/*",
|
|
@@ -81,12 +100,10 @@ describe("globalIpRateLimit (L1)", () => {
|
|
|
81
100
|
);
|
|
82
101
|
app.get("/api/probe", (c) => c.text("ok"));
|
|
83
102
|
|
|
84
|
-
// No xff header — extractIp returns undefined → middleware skips.
|
|
85
|
-
// Both calls succeed even though limit=1.
|
|
86
103
|
const a = await app.request("/api/probe");
|
|
87
|
-
const b = await app.request("/api/probe");
|
|
88
104
|
expect(a.status).toBe(200);
|
|
89
|
-
|
|
105
|
+
const b = await app.request("/api/probe");
|
|
106
|
+
expect(b.status).toBe(429);
|
|
90
107
|
});
|
|
91
108
|
|
|
92
109
|
test("fail-closed when resolver throws non-RateLimit error (Redis down)", async () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { isAuthRateLimitExempt } from "../api/api-constants";
|
|
3
|
+
import {
|
|
4
|
+
type ClientIpResolver,
|
|
5
|
+
clientIpSourceFromHonoContext,
|
|
6
|
+
createClientIpResolver,
|
|
7
|
+
} from "../api/client-ip";
|
|
3
8
|
import { requestContext } from "../api/request-context";
|
|
4
9
|
import { RateLimitError, serializeError } from "../errors";
|
|
5
10
|
import type { RateLimitDecision, RateLimitResolver } from "./resolver";
|
|
@@ -22,8 +27,17 @@ export type GlobalIpRateLimitOptions = {
|
|
|
22
27
|
readonly resolver: RateLimitResolver;
|
|
23
28
|
readonly limit?: number;
|
|
24
29
|
readonly windowSeconds?: number;
|
|
30
|
+
// Number of trusted reverse-proxy hops for the default client-IP
|
|
31
|
+
// resolver — see createClientIpResolver. Ignored when `extractIp` is set.
|
|
32
|
+
// Default 0 (no proxy trusted).
|
|
33
|
+
readonly trustedProxyHops?: number;
|
|
34
|
+
// Shared resolver instance from buildServer — takes precedence over
|
|
35
|
+
// `trustedProxyHops` so the whole server has exactly one resolver (and
|
|
36
|
+
// one warn-once flag) rather than one per middleware.
|
|
37
|
+
// Only standalone callers (no buildServer) should rely on `trustedProxyHops`.
|
|
38
|
+
readonly clientIpResolver?: ClientIpResolver;
|
|
25
39
|
// Override IP extraction — useful when behind a non-standard proxy.
|
|
26
|
-
// Default:
|
|
40
|
+
// Default: the shared resolveClientIp, honoring `trustedProxyHops`.
|
|
27
41
|
readonly extractIp?: (c: Context) => string | undefined;
|
|
28
42
|
// Hook for ops logging when fail-closed fires (Redis down). Default:
|
|
29
43
|
// emits to console.error so the misbehaviour is loud at minimum.
|
|
@@ -33,7 +47,11 @@ export type GlobalIpRateLimitOptions = {
|
|
|
33
47
|
export function globalIpRateLimit(opts: GlobalIpRateLimitOptions): MiddlewareHandler {
|
|
34
48
|
const limit = opts.limit ?? 1000;
|
|
35
49
|
const windowSeconds = opts.windowSeconds ?? 60;
|
|
36
|
-
const
|
|
50
|
+
const clientIpResolver =
|
|
51
|
+
opts.clientIpResolver ??
|
|
52
|
+
createClientIpResolver(opts.trustedProxyHops ?? 0, "globalIpRateLimit");
|
|
53
|
+
const extractIp =
|
|
54
|
+
opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
|
|
37
55
|
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l1-global-ip");
|
|
38
56
|
|
|
39
57
|
return async (c, next) => {
|
|
@@ -73,6 +91,13 @@ export type AuthEndpointRateLimitOptions = {
|
|
|
73
91
|
// top of IP. Default: bucket on `l2:${ip}:${path}` (IP + route),
|
|
74
92
|
// which catches naive IP-flood without consuming the request body.
|
|
75
93
|
readonly extractTarget?: (c: Context) => string | undefined | Promise<string | undefined>;
|
|
94
|
+
// Number of trusted reverse-proxy hops for the default client-IP
|
|
95
|
+
// resolver — see createClientIpResolver. Ignored when `extractIp` is set.
|
|
96
|
+
// Default 0 (no proxy trusted).
|
|
97
|
+
readonly trustedProxyHops?: number;
|
|
98
|
+
// See GlobalIpRateLimitOptions.clientIpResolver — same single-resolver
|
|
99
|
+
// rationale.
|
|
100
|
+
readonly clientIpResolver?: ClientIpResolver;
|
|
76
101
|
readonly extractIp?: (c: Context) => string | undefined;
|
|
77
102
|
readonly onFailClosed?: (err: unknown) => void;
|
|
78
103
|
};
|
|
@@ -80,7 +105,11 @@ export type AuthEndpointRateLimitOptions = {
|
|
|
80
105
|
export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): MiddlewareHandler {
|
|
81
106
|
const limit = opts.limit ?? 5;
|
|
82
107
|
const windowSeconds = opts.windowSeconds ?? 60;
|
|
83
|
-
const
|
|
108
|
+
const clientIpResolver =
|
|
109
|
+
opts.clientIpResolver ??
|
|
110
|
+
createClientIpResolver(opts.trustedProxyHops ?? 0, "authEndpointRateLimit");
|
|
111
|
+
const extractIp =
|
|
112
|
+
opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
|
|
84
113
|
const extractTarget = opts.extractTarget;
|
|
85
114
|
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l2-auth-endpoints");
|
|
86
115
|
|
|
@@ -110,11 +139,6 @@ export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): Middl
|
|
|
110
139
|
};
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
function defaultExtractIp(c: Context): string | undefined {
|
|
114
|
-
const xff = c.req.header("x-forwarded-for");
|
|
115
|
-
return xff?.split(",")[0]?.trim() || undefined;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
142
|
function defaultOnFailClosed(label: string): (err: unknown) => void {
|
|
119
143
|
return (err) => {
|
|
120
144
|
// Loud by default — fail-closed for an unknown reason is an ops
|
package/src/schema-cli.ts
CHANGED
|
@@ -115,7 +115,11 @@ export async function runSchemaCli(
|
|
|
115
115
|
// migration, since the crashed process still records the migration as
|
|
116
116
|
// applied and the rebuild is never retried.
|
|
117
117
|
await ensureTemporalPolyfill();
|
|
118
|
-
|
|
118
|
+
// `--help`/`-h`/`help` fall through to the same "no subcommand" usage
|
|
119
|
+
// branch below (undefined) — a real request for help, not a typo that
|
|
120
|
+
// should fail loud like an unrecognized subcommand does.
|
|
121
|
+
const rawSub = argv[0];
|
|
122
|
+
const sub = rawSub === "--help" || rawSub === "-h" || rawSub === "help" ? undefined : rawSub;
|
|
119
123
|
const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
|
|
120
124
|
const migrationsDir = resolvePath(appCwd, "kumiko/migrations");
|
|
121
125
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createTextField,
|
|
5
|
+
defineEntityCreateHandler,
|
|
6
|
+
defineFeature,
|
|
7
|
+
} from "../../engine";
|
|
8
|
+
import { pushEntityProjectionTables } from "../push-entity-projection-tables";
|
|
9
|
+
import { setupTestStack } from "../test-stack";
|
|
10
|
+
import { TestUsers } from "../test-users";
|
|
11
|
+
|
|
12
|
+
function freshDbName(tag: string): string {
|
|
13
|
+
return `kumiko_test_${tag}_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("pushEntityProjectionTables — persistent dev DB column sync", () => {
|
|
17
|
+
test("a nullable field added after the first boot is backfilled without manual DDL", async () => {
|
|
18
|
+
const dbName = freshDbName("colsync_nullable");
|
|
19
|
+
const widgetV1 = createEntity({
|
|
20
|
+
table: "colsync_nullable_widgets",
|
|
21
|
+
fields: {
|
|
22
|
+
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const v1 = defineFeature("colsync-nullable", (r) => {
|
|
26
|
+
r.entity("widget", widgetV1);
|
|
27
|
+
});
|
|
28
|
+
// Owns the actual DROP DATABASE on cleanup.
|
|
29
|
+
const firstBoot = await setupTestStack({ features: [v1], dbName });
|
|
30
|
+
try {
|
|
31
|
+
await pushEntityProjectionTables(firstBoot, firstBoot.registry);
|
|
32
|
+
|
|
33
|
+
const widgetV2 = createEntity({
|
|
34
|
+
table: "colsync_nullable_widgets",
|
|
35
|
+
fields: {
|
|
36
|
+
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
37
|
+
note: createTextField({ personal: false, reason: "test_fixture" }),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const v2 = defineFeature("colsync-nullable", (r) => {
|
|
41
|
+
r.entity("widget", widgetV2);
|
|
42
|
+
r.writeHandler(
|
|
43
|
+
defineEntityCreateHandler("widget", widgetV2, { access: { roles: ["Admin"] } }),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
const secondBoot = await setupTestStack({ features: [v2], dbName, persistentDb: true });
|
|
47
|
+
try {
|
|
48
|
+
await pushEntityProjectionTables(secondBoot, secondBoot.registry);
|
|
49
|
+
const created = await secondBoot.http.writeOk<{ data: { note: string } }>(
|
|
50
|
+
"colsync-nullable:write:widget:create",
|
|
51
|
+
{ name: "backfilled widget", note: "backfilled via HTTP write" },
|
|
52
|
+
TestUsers.admin,
|
|
53
|
+
);
|
|
54
|
+
expect(created.data.note).toBe("backfilled via HTTP write");
|
|
55
|
+
} finally {
|
|
56
|
+
await secondBoot.cleanup();
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
await firstBoot.cleanup();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a required field with no default added after the first boot fails with a clear, actionable error", async () => {
|
|
64
|
+
const dbName = freshDbName("colsync_required");
|
|
65
|
+
const v1 = defineFeature("colsync-required", (r) => {
|
|
66
|
+
r.entity(
|
|
67
|
+
"gadget",
|
|
68
|
+
createEntity({
|
|
69
|
+
table: "colsync_required_gadgets",
|
|
70
|
+
fields: {
|
|
71
|
+
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
const firstBoot = await setupTestStack({ features: [v1], dbName });
|
|
77
|
+
try {
|
|
78
|
+
await pushEntityProjectionTables(firstBoot, firstBoot.registry);
|
|
79
|
+
|
|
80
|
+
const v2 = defineFeature("colsync-required", (r) => {
|
|
81
|
+
r.entity(
|
|
82
|
+
"gadget",
|
|
83
|
+
createEntity({
|
|
84
|
+
table: "colsync_required_gadgets",
|
|
85
|
+
fields: {
|
|
86
|
+
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
87
|
+
serial: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
const secondBoot = await setupTestStack({ features: [v2], dbName, persistentDb: true });
|
|
93
|
+
try {
|
|
94
|
+
await expect(pushEntityProjectionTables(secondBoot, secondBoot.registry)).rejects.toThrow(
|
|
95
|
+
/serial.*no default/i,
|
|
96
|
+
);
|
|
97
|
+
await expect(pushEntityProjectionTables(secondBoot, secondBoot.registry)).rejects.toThrow(
|
|
98
|
+
/dropdb/,
|
|
99
|
+
);
|
|
100
|
+
} finally {
|
|
101
|
+
await secondBoot.cleanup();
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
await firstBoot.cleanup();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
-
import {
|
|
2
|
+
import type { DbConnection } from "../db/connection";
|
|
3
|
+
import type { ColumnMeta } from "../db/entity-table-meta";
|
|
4
|
+
import { columnNamesOf, tableExists } from "../db/schema-inspection";
|
|
3
5
|
import type { Registry } from "../engine/types";
|
|
4
|
-
import { unsafePushTables } from "./table-helpers";
|
|
5
|
-
|
|
6
|
+
import { addMissingColumns, tableToMeta, unsafePushTables } from "./table-helpers";
|
|
7
|
+
|
|
8
|
+
type DbHolder = { readonly db: DbConnection };
|
|
6
9
|
|
|
7
10
|
// biome-ignore lint/suspicious/noConsole: stack-internal status logging
|
|
8
11
|
const logInfo = (msg: string): void => console.log(msg);
|
|
@@ -25,7 +28,7 @@ const logInfo = (msg: string): void => console.log(msg);
|
|
|
25
28
|
* never call this directly.
|
|
26
29
|
*/
|
|
27
30
|
export async function pushEntityProjectionTables(
|
|
28
|
-
stack:
|
|
31
|
+
stack: DbHolder,
|
|
29
32
|
registry: Registry,
|
|
30
33
|
): Promise<void> {
|
|
31
34
|
const seen = new Set<unknown>();
|
|
@@ -37,14 +40,56 @@ export async function pushEntityProjectionTables(
|
|
|
37
40
|
seen.add(proj.table);
|
|
38
41
|
const tableRec = proj.table as unknown as Record<symbol, unknown>;
|
|
39
42
|
const physical = tableRec[KUMIKO_NAME_SYMBOL] as string;
|
|
40
|
-
if (await tableExists(stack.db, `public.${physical}`)) {
|
|
41
|
-
|
|
43
|
+
if (!(await tableExists(stack.db, `public.${physical}`))) {
|
|
44
|
+
missing[projName] = proj.table;
|
|
42
45
|
continue;
|
|
43
46
|
}
|
|
44
|
-
|
|
47
|
+
await syncExistingProjectionTable(stack, physical, proj.table);
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
if (Object.keys(missing).length > 0) {
|
|
48
51
|
await unsafePushTables(stack.db, missing);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
54
|
+
|
|
55
|
+
function isUnsafeToAutoAdd(col: ColumnMeta): boolean {
|
|
56
|
+
return col.notNull && !col.primaryKey && col.defaultSql === undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Backfills columns a persistent dev DB predates; a required column with no
|
|
60
|
+
// default can't be added safely on a table that may hold rows, so that
|
|
61
|
+
// stays a boot error instead.
|
|
62
|
+
async function syncExistingProjectionTable(
|
|
63
|
+
stack: DbHolder,
|
|
64
|
+
physical: string,
|
|
65
|
+
table: unknown,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const meta = tableToMeta(table);
|
|
68
|
+
const liveColumns = await columnNamesOf(stack.db, physical);
|
|
69
|
+
const missingColumns = meta.columns.filter((c) => !liveColumns.has(c.name));
|
|
70
|
+
if (missingColumns.length === 0) {
|
|
71
|
+
logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
|
|
72
|
+
// skip: live table already has every declared column
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const unsafeColumns = missingColumns.filter(isUnsafeToAutoAdd);
|
|
77
|
+
if (unsafeColumns.length > 0) {
|
|
78
|
+
const devDbName = process.env["KUMIKO_DEV_DB_NAME"] ?? "<KUMIKO_DEV_DB_NAME>";
|
|
79
|
+
throw new Error(
|
|
80
|
+
`[kumiko-stack] table "${physical}" is missing required column(s) ` +
|
|
81
|
+
`${unsafeColumns.map((c) => `"${c.name}"`).join(", ")} with no default. ` +
|
|
82
|
+
"Auto-sync only backfills nullable columns or columns with a default on an " +
|
|
83
|
+
"existing dev DB — a required column with no default can't be added safely " +
|
|
84
|
+
`once the table may hold rows. Drop the persistent dev database "${devDbName}" ` +
|
|
85
|
+
`(e.g. \`dropdb ${devDbName}\`, adjusted for your local Postgres host/port/user) ` +
|
|
86
|
+
"so the next boot recreates it from scratch.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logInfo(
|
|
91
|
+
`[kumiko-stack] table ${physical} is missing column(s) ` +
|
|
92
|
+
`${missingColumns.map((c) => `"${c.name}"`).join(", ")} — adding`,
|
|
93
|
+
);
|
|
94
|
+
await addMissingColumns(stack.db, physical, missingColumns);
|
|
95
|
+
}
|
|
@@ -67,7 +67,7 @@ function isMetaShape(v: unknown): v is EntityTableMeta {
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function tableToMeta(table: unknown): EntityTableMeta {
|
|
70
|
+
export function tableToMeta(table: unknown): EntityTableMeta {
|
|
71
71
|
// table() spreads column handles as enumerable props, so a field named
|
|
72
72
|
// `columns`/`tableName`/`source`/… shadows the matching meta key — read the
|
|
73
73
|
// canonical meta from the unshadowable symbol when present.
|
|
@@ -79,6 +79,20 @@ function tableToMeta(table: unknown): EntityTableMeta {
|
|
|
79
79
|
throw new Error("unsafePushTables: argument is not a SchemaTable / EntityTableMeta");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Applies ALTER TABLE ADD COLUMN for exactly the given columns. */
|
|
83
|
+
export async function addMissingColumns(
|
|
84
|
+
db: DbConnection,
|
|
85
|
+
tableName: string,
|
|
86
|
+
columns: readonly ColumnMeta[],
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
for (const col of columns) {
|
|
89
|
+
const type = renderColumnType(col);
|
|
90
|
+
const notNull = col.notNull && !col.primaryKey ? " NOT NULL" : "";
|
|
91
|
+
const defaultClause = col.defaultSql !== undefined ? ` DEFAULT ${col.defaultSql}` : "";
|
|
92
|
+
await alterTableAddColumn(db, tableName, col.name, type, defaultClause, notNull);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/**
|
|
83
97
|
* Bypass: pushes table definitions to the database directly. Produces
|
|
84
98
|
* CREATE TABLE IF NOT EXISTS + CREATE INDEX statements via renderTableDdl
|
|
@@ -106,14 +120,8 @@ export async function unsafePushTables(
|
|
|
106
120
|
|
|
107
121
|
if (prev) {
|
|
108
122
|
const prevCols = new Set(prev.columns.map((c) => c.name));
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const type = renderColumnType(col);
|
|
112
|
-
const notNull = col.notNull && !col.primaryKey ? " NOT NULL" : "";
|
|
113
|
-
const defaultClause = col.defaultSql !== undefined ? ` DEFAULT ${col.defaultSql}` : "";
|
|
114
|
-
await alterTableAddColumn(db, meta.tableName, col.name, type, defaultClause, notNull);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
123
|
+
const newCols = meta.columns.filter((c) => !prevCols.has(c.name));
|
|
124
|
+
await addMissingColumns(db, meta.tableName, newCols);
|
|
117
125
|
const prevIdxNames = new Set(prev.indexes.map((i) => i.name));
|
|
118
126
|
for (const idx of meta.indexes) {
|
|
119
127
|
if (!prevIdxNames.has(idx.name)) {
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
createIdempotencyGuard,
|
|
19
19
|
dispatcherToWriteRef,
|
|
20
20
|
} from "../pipeline";
|
|
21
|
+
import { createRateLimitResolver } from "../rate-limit";
|
|
21
22
|
import { createInMemorySearchAdapter } from "../search";
|
|
22
23
|
import type { SearchAdapter } from "../search/types";
|
|
23
24
|
import { createTestDb } from "./db";
|
|
@@ -187,6 +188,9 @@ export type TestStackOptions = {
|
|
|
187
188
|
* them themselves. Default true — false pushes only projection/MSP/
|
|
188
189
|
* storeTable sources, same as before fw#3102. */
|
|
189
190
|
entityTables?: boolean;
|
|
191
|
+
/** Forwarded to buildServer's top-level `ServerOptions.trustedProxyHops`
|
|
192
|
+
* — see there. Default 0. */
|
|
193
|
+
trustedProxyHops?: number;
|
|
190
194
|
};
|
|
191
195
|
|
|
192
196
|
const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
|
|
@@ -417,9 +421,31 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
417
421
|
const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
|
|
418
422
|
const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
|
|
419
423
|
|
|
424
|
+
// buildServer only auto-wires an L3 RateLimitResolver from
|
|
425
|
+
// `context.redis` — a degradation test that nulls `redis` via
|
|
426
|
+
// extraContext (to exercise a handler's own ctx.redis-down guard) also
|
|
427
|
+
// silently strips rate-limiting, even though every handler with a
|
|
428
|
+
// `rateLimit` declaration still requires a resolver at dispatch time.
|
|
429
|
+
// Since e7bf543ad, an IP-keyed bucket key is built unconditionally
|
|
430
|
+
// (falls back to a shared "unknown" IP rather than skipping), so the
|
|
431
|
+
// missing resolver now throws instead of merely skipping enforcement.
|
|
432
|
+
// Mirrors idempotency/eventDedup above: built from the test-stack's own
|
|
433
|
+
// always-real `testRedis.redis`, independent of whatever extraContext
|
|
434
|
+
// did to `appContext.redis` — an explicit `context.rateLimit` from
|
|
435
|
+
// extraContext still wins (same precedence buildServer documents).
|
|
436
|
+
// @cast-boundary extraContext is declared Record<string, unknown> (late,
|
|
437
|
+
// dynamic extension point) — appContext's inferred type doesn't carry a
|
|
438
|
+
// `rateLimit` key, so reading it back needs the same relaxed boundary
|
|
439
|
+
// extraContext itself is typed with.
|
|
440
|
+
const explicitRateLimit = (appContext as { rateLimit?: unknown }).rateLimit;
|
|
441
|
+
const rateLimitFallback =
|
|
442
|
+
appContext.redis === undefined && explicitRateLimit === undefined
|
|
443
|
+
? createRateLimitResolver({ redis: testRedis.redis })
|
|
444
|
+
: undefined;
|
|
445
|
+
|
|
420
446
|
const server = buildServer({
|
|
421
447
|
registry,
|
|
422
|
-
context: appContext,
|
|
448
|
+
context: rateLimitFallback ? { ...appContext, rateLimit: rateLimitFallback } : appContext,
|
|
423
449
|
jwtSecret,
|
|
424
450
|
...(options.observability ? { observability: options.observability } : {}),
|
|
425
451
|
dispatcherOptions: {
|
|
@@ -429,6 +455,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
429
455
|
},
|
|
430
456
|
eventDedup,
|
|
431
457
|
sseBroker,
|
|
458
|
+
...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
|
|
432
459
|
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
433
460
|
...(options.metrics && { metrics: options.metrics }),
|
|
434
461
|
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
package/src/ui-types/index.ts
CHANGED
|
@@ -64,7 +64,7 @@ export type {
|
|
|
64
64
|
RoleAccessPersonalData,
|
|
65
65
|
RoleAccessRule,
|
|
66
66
|
} from "../engine/types/handlers";
|
|
67
|
-
export { isOpenToAllGranted } from "../engine/types/handlers";
|
|
67
|
+
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers";
|
|
68
68
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav";
|
|
69
69
|
export type {
|
|
70
70
|
ActionFormRedirect,
|