@cosmicdrift/kumiko-bundled-features 0.270.0 → 0.274.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 +9 -9
- package/src/__tests__/account-security-fixture.ts +3 -0
- package/src/agent-tools/tool-catalog.ts +1 -1
- package/src/agent-tools/tool-dispatch.ts +1 -1
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
- package/src/audit/changes.json +12 -0
- package/src/audit/escape-hatch-audit-sink.ts +52 -0
- package/src/audit/feature.ts +5 -1
- package/src/audit/index.ts +1 -0
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
- package/src/auth-mfa/screens.ts +14 -10
- package/src/cap-counter/feature.ts +5 -3
- package/src/cap-overview/screens.ts +6 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
- package/src/config/changes.json +6 -0
- package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
- package/src/config/db/queries/resolver.ts +22 -22
- package/src/config/handlers/cascade.query.ts +6 -0
- package/src/config/handlers/readiness.query.ts +6 -0
- package/src/config/handlers/reencrypt.job.ts +21 -2
- package/src/config/handlers/schema.query.ts +6 -0
- package/src/config/handlers/values.query.ts +6 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +34 -0
- package/src/delivery/changes.json +6 -0
- package/src/delivery/delivery-service.ts +25 -3
- package/src/delivery/handlers/preferences.query.ts +6 -0
- package/src/delivery/testing.ts +8 -1
- package/src/jobs/handlers/list.query.ts +3 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
- package/src/managed-pages/screens/branding-screen.ts +3 -0
- package/src/managed-pages/screens/page-screens.ts +3 -0
- package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
- package/src/notes-history/web/i18n.ts +3 -0
- package/src/notes-history/web/notes-section.tsx +61 -51
- package/src/personal-access-tokens/screens.ts +7 -7
- package/src/sessions/screens.ts +5 -3
- package/src/tags/screens.ts +3 -0
- package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
- package/src/tenant/changes.json +6 -0
- package/src/tenant/handlers/me.query.ts +6 -0
- package/src/tenant/handlers/update-member-roles.write.ts +10 -1
- package/src/tenant/screens.ts +3 -0
- package/src/tenant/seeding.ts +25 -19
- package/src/tier-engine/feature.ts +3 -0
- package/src/user/changes.json +6 -0
- package/src/user/handlers/me.query.ts +6 -0
- package/src/user/handlers/update-roles.ts +8 -5
- package/src/user/screens.ts +3 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
- package/src/user-data-rights/feature.ts +3 -5
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
- package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
- package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
- package/src/user-data-rights/screens.ts +3 -0
- package/src/user-profile/feature.ts +3 -0
package/src/auth-mfa/screens.ts
CHANGED
|
@@ -10,13 +10,6 @@ import {
|
|
|
10
10
|
MFA_REGENERATE_RECOVERY_SCREEN_ID,
|
|
11
11
|
} from "./constants";
|
|
12
12
|
|
|
13
|
-
const MFA_ENABLE_OPEN_REASON =
|
|
14
|
-
"each signed-in user may enroll their own account in MFA, mirroring the enable-start handler";
|
|
15
|
-
const MFA_DISABLE_OPEN_REASON =
|
|
16
|
-
"each signed-in user may turn off their own MFA, mirroring the disable handler";
|
|
17
|
-
const MFA_REGENERATE_RECOVERY_OPEN_REASON =
|
|
18
|
-
"each signed-in user may regenerate their own recovery codes, mirroring the regenerate-recovery handler";
|
|
19
|
-
|
|
20
13
|
// Declarative TOTP-enrollment screen: mint (no input) -> reveal QR/secret/
|
|
21
14
|
// recovery-codes -> confirm with a 6-digit code. `setupToken` is carried
|
|
22
15
|
// from the mint payload into the confirm payload but never listed in
|
|
@@ -30,9 +23,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
30
23
|
submitLabel: i18nKey("mfa.enable.start"),
|
|
31
24
|
access: {
|
|
32
25
|
openToAll: {
|
|
33
|
-
reason:
|
|
26
|
+
reason:
|
|
27
|
+
"each signed-in user may enroll their own account in MFA, mirroring the enable-start handler",
|
|
34
28
|
},
|
|
35
29
|
},
|
|
30
|
+
// Reached only via a direct link from account settings, never from a
|
|
31
|
+
// list — no nav area to resolve in isolation.
|
|
32
|
+
dormant: true,
|
|
36
33
|
description:
|
|
37
34
|
"Self-service screen where a signed-in user enrolls in TOTP two-factor authentication: it shows the QR code and manual secret, reveals the recovery codes once, and confirms enrollment with a code from their authenticator app.",
|
|
38
35
|
reveal: {
|
|
@@ -86,9 +83,12 @@ export const mfaDisableScreen: ActionFormScreenDefinition = {
|
|
|
86
83
|
cancelTarget: false,
|
|
87
84
|
access: {
|
|
88
85
|
openToAll: {
|
|
89
|
-
reason:
|
|
86
|
+
reason: "each signed-in user may turn off their own MFA, mirroring the disable handler",
|
|
90
87
|
},
|
|
91
88
|
},
|
|
89
|
+
// Reached only via a direct link from account settings, never from a
|
|
90
|
+
// list — no nav area to resolve in isolation.
|
|
91
|
+
dormant: true,
|
|
92
92
|
description:
|
|
93
93
|
"Self-service screen where a signed-in user turns two-factor authentication off by entering a code from their authenticator app or a recovery code; their other sessions and access tokens are signed out.",
|
|
94
94
|
};
|
|
@@ -103,9 +103,13 @@ export const mfaRegenerateRecoveryScreen: SecretMintScreenDefinition = {
|
|
|
103
103
|
cancelTarget: false,
|
|
104
104
|
access: {
|
|
105
105
|
openToAll: {
|
|
106
|
-
reason:
|
|
106
|
+
reason:
|
|
107
|
+
"each signed-in user may regenerate their own recovery codes, mirroring the regenerate-recovery handler",
|
|
107
108
|
},
|
|
108
109
|
},
|
|
110
|
+
// Reached only via a direct link from account settings, never from a
|
|
111
|
+
// list — no nav area to resolve in isolation.
|
|
112
|
+
dormant: true,
|
|
109
113
|
description:
|
|
110
114
|
"Self-service screen where a signed-in user with two-factor authentication replaces all recovery codes after confirming with a current code; the new codes are shown once.",
|
|
111
115
|
reveal: {
|
|
@@ -66,8 +66,6 @@ import {
|
|
|
66
66
|
import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
|
|
67
67
|
import { CAP_COUNTER_I18N } from "./i18n";
|
|
68
68
|
|
|
69
|
-
const sysadminAccess = { access: { roles: access.systemAdmin }, crossTenant: true } as const;
|
|
70
|
-
|
|
71
69
|
export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
72
70
|
r.describe(
|
|
73
71
|
"Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.",
|
|
@@ -100,7 +98,11 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
100
98
|
// über aggregate-id).
|
|
101
99
|
r.queryHandler(
|
|
102
100
|
defineEntityListHandler("cap-counter", capCounterEntity, {
|
|
103
|
-
|
|
101
|
+
access: { roles: access.systemAdmin },
|
|
102
|
+
escapeHatch: {
|
|
103
|
+
reason:
|
|
104
|
+
"SystemAdmin quota operator view lists usage counters of every tenant to see who consumes which cap",
|
|
105
|
+
},
|
|
104
106
|
description:
|
|
105
107
|
"Lists usage-counter rows across all tenants with cap name, current value, period start and soft-warn timestamp; use it as the operator view of who is consuming which quota.",
|
|
106
108
|
}),
|
|
@@ -66,6 +66,9 @@ export function createTenantCapListScreen(
|
|
|
66
66
|
],
|
|
67
67
|
defaultSort: { field: "name", dir: "asc" },
|
|
68
68
|
access: { roles: ["SystemAdmin"] },
|
|
69
|
+
// No nav wiring yet (see feature.ts header) — no nav area to resolve
|
|
70
|
+
// in isolation.
|
|
71
|
+
dormant: true,
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -80,6 +83,9 @@ export const myCapsScreen: ScreenDefinition = {
|
|
|
80
83
|
},
|
|
81
84
|
],
|
|
82
85
|
access: { roles: MY_CAPS_ACCESS_ROLES },
|
|
86
|
+
// No nav wiring yet (see feature.ts header) — no nav area to resolve in
|
|
87
|
+
// isolation.
|
|
88
|
+
dormant: true,
|
|
83
89
|
};
|
|
84
90
|
|
|
85
91
|
export const platformTenantCapsScreen: ScreenDefinition = {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createTenantConfig,
|
|
20
20
|
defineFeature,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { ESCAPE_HATCH_USED_SIGNAL } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
22
23
|
import {
|
|
23
24
|
createEnvelopeCipher,
|
|
24
25
|
createEnvMasterKeyProvider,
|
|
@@ -158,7 +159,7 @@ describe("config KEK-rotation job — multi-tenant scan + write checks (kumiko-f
|
|
|
158
159
|
// No warn output — an over-restrictive systemDb binding would reject a
|
|
159
160
|
// row via assertRowsTenant, landing it in `failed` with a warn log
|
|
160
161
|
// instead of `migrated`.
|
|
161
|
-
expect(captured.warn).toEqual([]);
|
|
162
|
+
expect(captured.warn.filter((msg) => msg !== ESCAPE_HATCH_USED_SIGNAL)).toEqual([]);
|
|
162
163
|
|
|
163
164
|
const completeLine = captured.info.find((line) =>
|
|
164
165
|
line.includes("[config:reencrypt] complete:"),
|
package/src/config/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.271.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "systemScope handlers get a default per-tenant+handler rate limit; the four read queries opt out (fw#2861).",
|
|
6
|
+
"detail": "`config` mounts under `r.systemScope()`, so `config:write:set`/`config:write:reset` without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `config:query:cascade`, `config:query:values`, `config:query:schema` and `config:query:readiness` declare `rateLimit: { disabled: true, reason: \"...\" }` — each is a self-scoped read of the caller's own readable config, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.165.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTenantDb, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
4
|
import { selectConfigRowsForKeys, selectConfigRowsForScope } from "../resolver";
|
|
3
5
|
|
|
4
|
-
function closedConnectionError(): Error {
|
|
5
|
-
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
6
|
-
}
|
|
7
|
-
|
|
8
6
|
type FakeClient = {
|
|
9
7
|
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
10
8
|
begin: () => never;
|
|
11
9
|
calls: number;
|
|
12
10
|
};
|
|
13
11
|
|
|
14
|
-
function fakeClient(
|
|
15
|
-
const remaining = [...failures];
|
|
12
|
+
function fakeClient(rowsPerCall: readonly (readonly Record<string, unknown>[])[]): FakeClient {
|
|
16
13
|
const client: FakeClient = {
|
|
17
14
|
calls: 0,
|
|
18
15
|
unsafe: async () => {
|
|
16
|
+
const rows = rowsPerCall[client.calls] ?? [];
|
|
19
17
|
client.calls++;
|
|
20
|
-
|
|
21
|
-
if (err) throw err;
|
|
22
|
-
return [{ id: "r1", key: "k", value: "v", tenantId: "t1", userId: null }];
|
|
18
|
+
return rows;
|
|
23
19
|
},
|
|
24
|
-
// begin() present => pool client => retry path
|
|
25
20
|
begin: () => {
|
|
26
21
|
throw new Error("not used in test");
|
|
27
22
|
},
|
|
@@ -29,44 +24,54 @@ function fakeClient(failures: Error[]): FakeClient {
|
|
|
29
24
|
return client;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
describe("config db/queries/resolver —
|
|
33
|
-
test("selectConfigRowsForScope
|
|
34
|
-
const db = fakeClient([
|
|
27
|
+
describe("config db/queries/resolver — scope-bucket merge", () => {
|
|
28
|
+
test("selectConfigRowsForScope merges the scope bucket and the user bucket", async () => {
|
|
29
|
+
const db = fakeClient([
|
|
30
|
+
[{ id: "sys-1", key: "k1", value: "v-sys", tenantId: "system", userId: null }],
|
|
31
|
+
[{ id: "user-1", key: "k1", value: "v-user", tenantId: "t1", userId: "u1" }],
|
|
32
|
+
]);
|
|
35
33
|
const rows = await selectConfigRowsForScope(db as never, "system", "t1", "u1");
|
|
36
|
-
expect(rows).toHaveLength(1);
|
|
37
|
-
expect(rows[0]?.key).toBe("k");
|
|
38
34
|
expect(db.calls).toBe(2);
|
|
35
|
+
expect(rows.map((r) => r.id).sort()).toEqual(["sys-1", "user-1"]);
|
|
39
36
|
});
|
|
40
37
|
|
|
41
|
-
test("selectConfigRowsForKeys
|
|
42
|
-
const db = fakeClient([
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
test("selectConfigRowsForKeys merges the scope bucket and the user bucket", async () => {
|
|
39
|
+
const db = fakeClient([
|
|
40
|
+
[{ id: "sys-1", key: "k1", value: "v-sys", tenantId: "system", userId: null }],
|
|
41
|
+
[{ id: "user-1", key: "k1", value: "v-user", tenantId: "t1", userId: "u1" }],
|
|
42
|
+
]);
|
|
43
|
+
const rows = await selectConfigRowsForKeys(db as never, ["k1"], "system", "t1", "u1");
|
|
45
44
|
expect(db.calls).toBe(2);
|
|
45
|
+
expect(rows.map((r) => r.id).sort()).toEqual(["sys-1", "user-1"]);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
test("
|
|
49
|
-
const db = fakeClient([
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
);
|
|
53
|
-
expect(db.calls).toBe(2);
|
|
48
|
+
test("selectConfigRowsForKeys returns [] without querying when keys is empty", async () => {
|
|
49
|
+
const db = fakeClient([]);
|
|
50
|
+
const rows = await selectConfigRowsForKeys(db as never, [], "system", "t1", "u1");
|
|
51
|
+
expect(rows).toEqual([]);
|
|
52
|
+
expect(db.calls).toBe(0);
|
|
54
53
|
});
|
|
54
|
+
});
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
describe("config db/queries/resolver — tenant-mode TenantDb narrowing", () => {
|
|
57
|
+
test("selectConfigRowsForScope's tenantId array survives readWhere's narrowing", async () => {
|
|
58
|
+
const captured: { sql: string; values: readonly unknown[] }[] = [];
|
|
59
|
+
const recordingRunner = {
|
|
60
|
+
unsafe: async (sql: string, values: readonly unknown[]) => {
|
|
61
|
+
captured.push({ sql, values });
|
|
62
|
+
return [] as unknown[];
|
|
62
63
|
},
|
|
63
|
-
|
|
64
|
-
throw new Error("not used");
|
|
64
|
+
begin: async () => {
|
|
65
|
+
throw new Error("not used in test");
|
|
65
66
|
},
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
|
|
67
|
+
} as unknown as DbRunner;
|
|
68
|
+
const tdb = createTenantDb(recordingRunner, "t1");
|
|
69
|
+
|
|
70
|
+
await selectConfigRowsForScope(tdb, SYSTEM_TENANT_ID, "t1", "u1");
|
|
71
|
+
|
|
72
|
+
expect(captured).toHaveLength(2);
|
|
73
|
+
expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
|
|
74
|
+
expect(captured[0]?.values).toContain(SYSTEM_TENANT_ID);
|
|
75
|
+
expect(captured[0]?.values).toContain("t1");
|
|
71
76
|
});
|
|
72
77
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { configValuesTable } from "../../table";
|
|
4
5
|
|
|
5
6
|
export type ConfigRow = {
|
|
6
7
|
readonly id: string;
|
|
@@ -10,21 +11,21 @@ export type ConfigRow = {
|
|
|
10
11
|
readonly userId: string | null;
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
// Method-form so a tenant-mode TenantDb stays filtered.
|
|
13
15
|
export async function selectConfigRowsForScope(
|
|
14
16
|
db: DbRunner | TenantDb,
|
|
15
17
|
systemTenantId: TenantId,
|
|
16
18
|
tenantId: TenantId,
|
|
17
19
|
userId: string,
|
|
18
20
|
): Promise<readonly ConfigRow[]> {
|
|
19
|
-
|
|
20
|
-
db,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
21
|
+
const [scopeRows, userRows] = await Promise.all([
|
|
22
|
+
selectMany<ConfigRow>(db, configValuesTable, {
|
|
23
|
+
tenantId: [systemTenantId, tenantId],
|
|
24
|
+
userId: null,
|
|
25
|
+
}),
|
|
26
|
+
selectMany<ConfigRow>(db, configValuesTable, { tenantId, userId }),
|
|
27
|
+
]);
|
|
28
|
+
return [...scopeRows, ...userRows];
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export async function selectConfigRowsForKeys(
|
|
@@ -34,16 +35,15 @@ export async function selectConfigRowsForKeys(
|
|
|
34
35
|
tenantId: TenantId,
|
|
35
36
|
userId: string,
|
|
36
37
|
): Promise<readonly ConfigRow[]> {
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
38
|
+
if (keys.length === 0) return [];
|
|
39
|
+
const keyFilter = { in: [...keys] };
|
|
40
|
+
const [scopeRows, userRows] = await Promise.all([
|
|
41
|
+
selectMany<ConfigRow>(db, configValuesTable, {
|
|
42
|
+
key: keyFilter,
|
|
43
|
+
tenantId: [systemTenantId, tenantId],
|
|
44
|
+
userId: null,
|
|
45
|
+
}),
|
|
46
|
+
selectMany<ConfigRow>(db, configValuesTable, { key: keyFilter, tenantId, userId }),
|
|
47
|
+
]);
|
|
48
|
+
return [...scopeRows, ...userRows];
|
|
49
49
|
}
|
|
@@ -23,6 +23,12 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
23
23
|
"only the config keys the caller's roles may read, before any value is returned",
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
|
+
rateLimit: {
|
|
27
|
+
disabled: true,
|
|
28
|
+
reason:
|
|
29
|
+
"self-scoped read of the caller's own readable config cascade, hit on every page load; " +
|
|
30
|
+
"per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
31
|
+
},
|
|
26
32
|
handler: async (query, ctx) => {
|
|
27
33
|
const db = requireSystemDb(ctx, "config:query:cascade", query.user.tenantId);
|
|
28
34
|
const registry = ctx.registry;
|
|
@@ -138,6 +138,12 @@ export const readinessQuery = defineQueryHandler({
|
|
|
138
138
|
"candidate keys through hasConfigAccess so only keys the caller's roles may read are reported",
|
|
139
139
|
},
|
|
140
140
|
},
|
|
141
|
+
rateLimit: {
|
|
142
|
+
disabled: true,
|
|
143
|
+
reason:
|
|
144
|
+
"self-scoped read of the caller's own tenant's config readiness, hit on every page load; " +
|
|
145
|
+
"per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
146
|
+
},
|
|
141
147
|
handler: async (query, ctx) => {
|
|
142
148
|
const db = requireSystemDb(ctx, "config:query:readiness", query.user.tenantId);
|
|
143
149
|
const missing = await collectMissingRequiredConfig(
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type TenantId,
|
|
39
39
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
40
40
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
41
|
+
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
41
42
|
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
42
43
|
import {
|
|
43
44
|
type ChunkedMigrationStopReason,
|
|
@@ -107,6 +108,16 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
107
108
|
? Date.now() + payload.maxDurationMs
|
|
108
109
|
: Number.POSITIVE_INFINITY;
|
|
109
110
|
|
|
111
|
+
function reportEscapeHatchFor(tenantId: TenantId) {
|
|
112
|
+
return createEscapeHatchReporter({
|
|
113
|
+
handler: "config:job:reencrypt",
|
|
114
|
+
tenantId,
|
|
115
|
+
actor: "system",
|
|
116
|
+
sink: ctx._escapeHatchAuditSink,
|
|
117
|
+
log: ctx.log,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
110
121
|
const tdbCache = new Map<TenantId, TenantDb>();
|
|
111
122
|
function tdbFor(tenantId: TenantId): TenantDb {
|
|
112
123
|
let existing = tdbCache.get(tenantId);
|
|
@@ -121,7 +132,11 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
121
132
|
function sdbFor(tenantId: TenantId): UncheckedSystemDb {
|
|
122
133
|
let existing = sdbCache.get(tenantId);
|
|
123
134
|
if (!existing) {
|
|
124
|
-
existing = createUncheckedSystemDb(
|
|
135
|
+
existing = createUncheckedSystemDb(
|
|
136
|
+
tdbFor(tenantId),
|
|
137
|
+
undefined,
|
|
138
|
+
reportEscapeHatchFor(tenantId),
|
|
139
|
+
);
|
|
125
140
|
sdbCache.set(tenantId, existing);
|
|
126
141
|
}
|
|
127
142
|
return existing;
|
|
@@ -131,7 +146,11 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
131
146
|
// to acknowledge the cross-tenant scan below, never bound to a real
|
|
132
147
|
// tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
|
|
133
148
|
// write-path cache entry double as the scan's ack gate.
|
|
134
|
-
const scanDb = createUncheckedSystemDb(
|
|
149
|
+
const scanDb = createUncheckedSystemDb(
|
|
150
|
+
createTenantDb(db, SYSTEM_TENANT_ID, "system"),
|
|
151
|
+
undefined,
|
|
152
|
+
reportEscapeHatchFor(SYSTEM_TENANT_ID),
|
|
153
|
+
);
|
|
135
154
|
|
|
136
155
|
type ConfigRow = {
|
|
137
156
|
id: string;
|
|
@@ -15,6 +15,12 @@ export const schemaQuery = defineQueryHandler({
|
|
|
15
15
|
"only the config key definitions the caller's roles may read",
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
|
+
rateLimit: {
|
|
19
|
+
disabled: true,
|
|
20
|
+
reason:
|
|
21
|
+
"self-scoped read of the caller's own readable config schema, hit on every page load; " +
|
|
22
|
+
"per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
23
|
+
},
|
|
18
24
|
handler: async (query, ctx) => {
|
|
19
25
|
const registry = ctx.registry;
|
|
20
26
|
const allKeys = registry.getAllConfigKeys();
|
|
@@ -23,6 +23,12 @@ export const valuesQuery = defineQueryHandler({
|
|
|
23
23
|
"only the config keys the caller's roles may read, before any value is returned",
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
|
+
rateLimit: {
|
|
27
|
+
disabled: true,
|
|
28
|
+
reason:
|
|
29
|
+
"self-scoped read of the caller's own readable config values, hit on every page load; " +
|
|
30
|
+
"per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
31
|
+
},
|
|
26
32
|
handler: async (query, ctx) => {
|
|
27
33
|
const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
|
|
28
34
|
const registry = ctx.registry;
|
|
@@ -1293,6 +1293,40 @@ describe("flow 15: idempotency requires Redis", () => {
|
|
|
1293
1293
|
});
|
|
1294
1294
|
});
|
|
1295
1295
|
|
|
1296
|
+
// --- Flow 15b: escape-hatch audit attribution for tenant-broadcast resolveUserIdsForTenant ---
|
|
1297
|
+
|
|
1298
|
+
describe("flow 15b: escape-hatch audit for tenant broadcast", () => {
|
|
1299
|
+
test("recording sink receives an acknowledge-cross-tenant event attributed to tenantUserIdsQuery", async () => {
|
|
1300
|
+
const recorded: import("@cosmicdrift/kumiko-framework/engine").EscapeHatchUseEvent[] = [];
|
|
1301
|
+
const auditedService = createDeliveryService({
|
|
1302
|
+
db,
|
|
1303
|
+
registry: stack.registry,
|
|
1304
|
+
channels: collectChannels(stack.registry),
|
|
1305
|
+
tenantUserIdsQuery: TenantQueries.resolveUserIds,
|
|
1306
|
+
escapeHatchAuditSink: async (event) => {
|
|
1307
|
+
recorded.push(event);
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// Distinct tenantId (not used by any earlier test in this file) — the
|
|
1312
|
+
// module-level escape-hatch dedup window suppresses a repeat report for
|
|
1313
|
+
// an identical (handler, kind, reason, tenantId, actor) tuple within 60s,
|
|
1314
|
+
// which would otherwise make this assertion flaky depending on test order.
|
|
1315
|
+
const auditTenantId = testTenantId(915501);
|
|
1316
|
+
|
|
1317
|
+
await auditedService.notify(
|
|
1318
|
+
"app:notify:tenant-alert",
|
|
1319
|
+
{ to: { tenant: auditTenantId }, data: { title: "X", body: "X" } },
|
|
1320
|
+
admin,
|
|
1321
|
+
admin.tenantId,
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
const event = recorded.find((e) => e.kind === "acknowledge-cross-tenant");
|
|
1325
|
+
expect(event?.handler).toBe(TenantQueries.resolveUserIds);
|
|
1326
|
+
expect(event?.tenantId).toBe(auditTenantId);
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1296
1330
|
// --- Flow 16: Unsubscribe race — ON CONFLICT makes repeated clicks safe ---
|
|
1297
1331
|
|
|
1298
1332
|
describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.271.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "systemScope handlers get a default per-tenant+handler rate limit; delivery:preferences opts out (fw#2861).",
|
|
6
|
+
"detail": "`delivery` mounts under `r.systemScope()`, so `delivery:write:set-preference`/`delivery:query:log` without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `delivery:query:preferences` declares `rateLimit: { disabled: true, reason: \"...\" }` — it is a self-scoped read of the caller's own notification preference rows, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.208.2",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createTenantDb, createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
EscapeHatchAuditSink,
|
|
6
|
+
NotifyPriority,
|
|
7
|
+
Registry,
|
|
8
|
+
TenantId,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
10
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
11
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
12
|
+
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
13
|
+
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
7
14
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
8
15
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
16
|
import type { Redis } from "ioredis";
|
|
@@ -43,6 +50,10 @@ export type DeliveryServiceOptions = {
|
|
|
43
50
|
// the delivery.render → delivery.send jobs. When absent, queued channels fall
|
|
44
51
|
// back to synchronous inline delivery (job-less setups, unit tests).
|
|
45
52
|
readonly jobRunner?: JobRunner;
|
|
53
|
+
// Attributed escape-hatch audit for resolveUserIdsForTenant's ctx.systemDb —
|
|
54
|
+
// absent means an unattributed warn log (see fallbackEscapeHatchReporter).
|
|
55
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
56
|
+
readonly log?: Logger;
|
|
46
57
|
};
|
|
47
58
|
|
|
48
59
|
// Build channel list from registry extension usages
|
|
@@ -82,6 +93,8 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
82
93
|
isChannelKilled,
|
|
83
94
|
idempotencyRedis,
|
|
84
95
|
jobRunner,
|
|
96
|
+
escapeHatchAuditSink,
|
|
97
|
+
log,
|
|
85
98
|
} = options;
|
|
86
99
|
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
87
100
|
|
|
@@ -157,7 +170,16 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
157
170
|
throw new Error(`Tenant broadcast query "${tenantUserIdsQuery}" not found in registry`);
|
|
158
171
|
}
|
|
159
172
|
const systemUser = createSystemUser(tenantId);
|
|
160
|
-
const
|
|
173
|
+
const report = createEscapeHatchReporter({
|
|
174
|
+
handler: tenantUserIdsQuery,
|
|
175
|
+
tenantId,
|
|
176
|
+
actor: systemUser.id,
|
|
177
|
+
sink: escapeHatchAuditSink,
|
|
178
|
+
log,
|
|
179
|
+
});
|
|
180
|
+
const tenantDb = createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
|
|
181
|
+
report,
|
|
182
|
+
});
|
|
161
183
|
// Hand-built context, not routed through the dispatcher — tenantUserIdsQuery is
|
|
162
184
|
// typically an r.systemScope() handler, fail-closed on ctx.db, so this needs both.
|
|
163
185
|
// @cast-boundary engine-payload — generic query-handler return for typed convention
|
|
@@ -166,7 +188,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
166
188
|
{
|
|
167
189
|
db: tenantDb,
|
|
168
190
|
dbOutsideTransaction: tenantDb,
|
|
169
|
-
systemDb: createUncheckedSystemDb(tenantDb),
|
|
191
|
+
systemDb: createUncheckedSystemDb(tenantDb, undefined, report),
|
|
170
192
|
registry,
|
|
171
193
|
...bridgeStub(),
|
|
172
194
|
},
|
|
@@ -14,6 +14,12 @@ export const preferencesQuery = defineQueryHandler({
|
|
|
14
14
|
"query filters notificationPreferencesTable by the caller's own tenantId and userId",
|
|
15
15
|
},
|
|
16
16
|
},
|
|
17
|
+
rateLimit: {
|
|
18
|
+
disabled: true,
|
|
19
|
+
reason:
|
|
20
|
+
"self-scoped read of the caller's own notification preference rows, hit on every page " +
|
|
21
|
+
"load; per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
22
|
+
},
|
|
17
23
|
description:
|
|
18
24
|
"Returns the calling user's notification preference rows for their own tenant; use it to show which notification types and channels that user has enabled or muted.",
|
|
19
25
|
handler: async (query, ctx) => {
|
package/src/delivery/testing.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
EscapeHatchAuditSink,
|
|
5
|
+
Registry,
|
|
6
|
+
TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
9
|
import type { Redis } from "ioredis";
|
|
5
10
|
import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service";
|
|
6
11
|
import { collectChannels, createDeliveryService } from "./delivery-service";
|
|
@@ -10,6 +15,8 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
10
15
|
readonly tenantUserIdsQuery?: string;
|
|
11
16
|
readonly rateLimit?: RateLimitConfig;
|
|
12
17
|
readonly isChannelKilled?: KillSwitchResolver;
|
|
18
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
19
|
+
readonly log?: Logger;
|
|
13
20
|
};
|
|
14
21
|
|
|
15
22
|
/**
|
|
@@ -43,7 +43,9 @@ export const listQuery = defineQueryHandler({
|
|
|
43
43
|
message: "jobs:query:list requires ctx.systemDb (feature must declare r.systemScope())",
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
-
const db = ctx.systemDb.
|
|
46
|
+
const db = ctx.systemDb.unsafeRaw(
|
|
47
|
+
"cross-tenant job monitoring: SystemAdmin lists job runs of every tenant",
|
|
48
|
+
);
|
|
47
49
|
const where: WhereObject = {};
|
|
48
50
|
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
49
51
|
const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
|