@cosmicdrift/kumiko-framework 0.318.0 → 0.320.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +8 -4
  2. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  4. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  5. package/src/api/__tests__/client-ip.test.ts +139 -0
  6. package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
  7. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  8. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  9. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  10. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  11. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  12. package/src/api/api-constants.ts +2 -0
  13. package/src/api/auth-routes.ts +92 -95
  14. package/src/api/client-ip.ts +91 -0
  15. package/src/api/index.ts +15 -0
  16. package/src/api/pat-route-guard.ts +51 -0
  17. package/src/api/post-auth-landing.ts +105 -0
  18. package/src/api/request-context.ts +4 -2
  19. package/src/api/request-id-middleware.ts +44 -14
  20. package/src/api/server.ts +128 -40
  21. package/src/changes.json +73 -0
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  23. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  24. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  25. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  26. package/src/engine/boot-validator/pii-retention.ts +18 -2
  27. package/src/engine/boot-validator/screens.ts +75 -18
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/project-app-schema-for-roles.ts +950 -0
  30. package/src/engine/types/handlers.ts +1 -1
  31. package/src/engine/types/index.ts +5 -1
  32. package/src/entrypoint/index.ts +14 -1
  33. package/src/files/__tests__/files.integration.test.ts +105 -1
  34. package/src/files/file-routes.ts +54 -2
  35. package/src/files/index.ts +1 -1
  36. package/src/http/index.ts +10 -2
  37. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  38. package/src/jobs/job-runner.ts +6 -1
  39. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  40. package/src/rate-limit/middleware.ts +32 -8
  41. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  42. package/src/stack/push-entity-projection-tables.ts +52 -7
  43. package/src/stack/table-helpers.ts +17 -9
  44. package/src/stack/test-stack.ts +28 -1
  45. package/src/testing/real-providers.ts +2 -0
  46. package/src/ui-types/index.ts +1 -1
@@ -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 { tableExists } from "../db/schema-inspection";
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
- import type { TestStack } from "./test-stack";
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: TestStack,
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
- logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
43
+ if (!(await tableExists(stack.db, `public.${physical}`))) {
44
+ missing[projName] = proj.table;
42
45
  continue;
43
46
  }
44
- missing[projName] = proj.table;
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
- for (const col of meta.columns) {
110
- if (!prevCols.has(col.name)) {
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)) {
@@ -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
@@ -1,3 +1,5 @@
1
+ // Playwright configs load this module under plain Node via
2
+ // @cosmicdrift/kumiko-testing/e2e, so it must stay import-free.
1
3
  export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
2
4
  const CI_FLAG = "CI";
3
5
 
@@ -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,