@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.273.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-composition.boot.test.ts +25 -7
- package/src/__tests__/account-security-composition.integration.test.ts +23 -1
- package/src/__tests__/account-security-fixture.ts +3 -0
- package/src/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
- package/src/agent-tools/__tests__/feature.test.ts +10 -4
- 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-email-password/handlers/invite-accept.write.ts +8 -1
- package/src/auth-mfa/handlers/disable.write.ts +7 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
- package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
- package/src/auth-mfa/handlers/status.query.ts +7 -1
- package/src/auth-mfa/screens.ts +26 -3
- package/src/cap-counter/feature.ts +5 -3
- package/src/cap-overview/screens.ts +6 -0
- package/src/channel-in-app/handlers/inbox.query.ts +7 -1
- package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
- package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
- package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
- package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
- package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
- package/src/config/__tests__/config.integration.test.ts +1 -1
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
- package/src/config/__tests__/tz-resolution.integration.test.ts +1 -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 +13 -1
- package/src/config/handlers/readiness.query.ts +13 -1
- package/src/config/handlers/reencrypt.job.ts +21 -2
- package/src/config/handlers/reset.write.ts +7 -1
- package/src/config/handlers/schema.query.ts +13 -1
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/handlers/values.query.ts +13 -1
- package/src/data-retention/handlers/policy-for.query.ts +8 -1
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +39 -5
- package/src/delivery/changes.json +6 -0
- package/src/delivery/delivery-service.ts +25 -3
- package/src/delivery/handlers/preferences.query.ts +13 -1
- package/src/delivery/handlers/set-preference.write.ts +7 -1
- package/src/delivery/testing.ts +8 -1
- package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
- package/src/folders/__tests__/feature.test.ts +14 -5
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
- package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
- package/src/folders/__tests__/folders.integration.test.ts +6 -1
- package/src/folders/feature.ts +2 -2
- package/src/form-draft/constants.ts +7 -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-read-gate.integration.test.ts +10 -2
- package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
- package/src/notes-history/feature.ts +2 -2
- package/src/notes-history/web/i18n.ts +3 -0
- package/src/notes-history/web/notes-section.tsx +61 -51
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
- package/src/personal-access-tokens/handlers/create.write.ts +7 -1
- package/src/personal-access-tokens/handlers/list.query.ts +7 -1
- package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
- package/src/personal-access-tokens/screens.ts +15 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
- package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
- package/src/secrets/feature.ts +2 -2
- package/src/sessions/handlers/mine.query.ts +7 -1
- package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
- package/src/sessions/handlers/revoke.write.ts +7 -1
- package/src/sessions/screens.ts +9 -1
- package/src/tags/__tests__/feature.test.ts +25 -8
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
- package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
- package/src/tags/__tests__/tags.integration.test.ts +6 -1
- package/src/tags/constants.ts +1 -1
- package/src/tags/feature.ts +2 -2
- package/src/tags/screens.ts +3 -0
- package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
- package/src/tenant/changes.json +6 -0
- package/src/tenant/handlers/me.query.ts +13 -1
- 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 +13 -1
- 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/__tests__/inspector-screens.boot.test.ts +6 -1
- package/src/user-data-rights/feature.ts +11 -6
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +7 -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/handlers/export-status.query.ts +7 -1
- package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
- package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
- package/src/user-data-rights/handlers/request-export.write.ts +7 -1
- package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
- package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
- package/src/user-data-rights/screens.ts +3 -0
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
- package/src/user-profile/feature.ts +11 -1
- package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
|
@@ -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:"),
|
|
@@ -38,7 +38,7 @@ const probeFeature = defineFeature("probe", (r) => {
|
|
|
38
38
|
isSuccess: true,
|
|
39
39
|
data: { tenant: ctx.tz.tenant, user: ctx.tz.user },
|
|
40
40
|
}),
|
|
41
|
-
{ access: { openToAll:
|
|
41
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
42
42
|
);
|
|
43
43
|
});
|
|
44
44
|
|
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
|
}
|
|
@@ -16,7 +16,19 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
16
16
|
schema: z.object({
|
|
17
17
|
keys: z.array(z.string()).optional(),
|
|
18
18
|
}),
|
|
19
|
-
access: {
|
|
19
|
+
access: {
|
|
20
|
+
openToAll: {
|
|
21
|
+
reason:
|
|
22
|
+
"any signed-in user may call cascade; hasConfigAccess filters the result to " +
|
|
23
|
+
"only the config keys the caller's roles may read, before any value is returned",
|
|
24
|
+
},
|
|
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
|
+
},
|
|
20
32
|
handler: async (query, ctx) => {
|
|
21
33
|
const db = requireSystemDb(ctx, "config:query:cascade", query.user.tenantId);
|
|
22
34
|
const registry = ctx.registry;
|
|
@@ -131,7 +131,19 @@ export const readinessQuery = defineQueryHandler({
|
|
|
131
131
|
"Lists the required config keys that are still unset for the caller's tenant, each with its scope and type; use it to find out what is missing before a feature can run.",
|
|
132
132
|
schema: z.object({}),
|
|
133
133
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
134
|
-
access: {
|
|
134
|
+
access: {
|
|
135
|
+
openToAll: {
|
|
136
|
+
reason:
|
|
137
|
+
"any signed-in user may call readiness; collectMissingRequiredConfig filters " +
|
|
138
|
+
"candidate keys through hasConfigAccess so only keys the caller's roles may read are reported",
|
|
139
|
+
},
|
|
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
|
+
},
|
|
135
147
|
handler: async (query, ctx) => {
|
|
136
148
|
const db = requireSystemDb(ctx, "config:query:readiness", query.user.tenantId);
|
|
137
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;
|
|
@@ -26,7 +26,13 @@ export const resetWrite = defineWriteHandler({
|
|
|
26
26
|
scope: scopeEnum.optional(),
|
|
27
27
|
}),
|
|
28
28
|
// Per-key access enforcement lives inside the handler via checkWriteAccess.
|
|
29
|
-
access: {
|
|
29
|
+
access: {
|
|
30
|
+
openToAll: {
|
|
31
|
+
reason:
|
|
32
|
+
"any signed-in user may call reset; prepareConfigWrite enforces the per-key " +
|
|
33
|
+
"write access rule before any value is touched",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
30
36
|
handler: async (event, ctx) => {
|
|
31
37
|
const db = requireSystemDb(ctx, "config:write:reset", event.user.tenantId);
|
|
32
38
|
|
|
@@ -8,7 +8,19 @@ export const schemaQuery = defineQueryHandler({
|
|
|
8
8
|
"Returns the definitions of all config keys the caller may read (scope, type, default, bounds, required flag) without any values; use it to discover which settings exist and how they may be set.",
|
|
9
9
|
schema: z.object({}),
|
|
10
10
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
11
|
-
access: {
|
|
11
|
+
access: {
|
|
12
|
+
openToAll: {
|
|
13
|
+
reason:
|
|
14
|
+
"any signed-in user may call schema; hasConfigAccess filters the result to " +
|
|
15
|
+
"only the config key definitions the caller's roles may read",
|
|
16
|
+
},
|
|
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
|
+
},
|
|
12
24
|
handler: async (query, ctx) => {
|
|
13
25
|
const registry = ctx.registry;
|
|
14
26
|
const allKeys = registry.getAllConfigKeys();
|
|
@@ -44,7 +44,13 @@ export const setWrite = defineWriteHandler({
|
|
|
44
44
|
scope: scopeEnum.optional(),
|
|
45
45
|
}),
|
|
46
46
|
// Per-key access enforcement lives inside the handler via checkWriteAccess.
|
|
47
|
-
access: {
|
|
47
|
+
access: {
|
|
48
|
+
openToAll: {
|
|
49
|
+
reason:
|
|
50
|
+
"any signed-in user may call set; prepareConfigWrite enforces the per-key " +
|
|
51
|
+
"write access rule before any value is stored",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
48
54
|
handler: async (event, ctx) => {
|
|
49
55
|
const db = requireSystemDb(ctx, "config:write:set", event.user.tenantId);
|
|
50
56
|
|
|
@@ -16,7 +16,19 @@ export const valuesQuery = defineQueryHandler({
|
|
|
16
16
|
"Returns the effective value, scope and winning source for every config key the caller may read, with encrypted and secret-backed values masked; use it to inspect the current settings.",
|
|
17
17
|
schema: z.object({}),
|
|
18
18
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
19
|
-
access: {
|
|
19
|
+
access: {
|
|
20
|
+
openToAll: {
|
|
21
|
+
reason:
|
|
22
|
+
"any signed-in user may call values; hasConfigAccess filters the result to " +
|
|
23
|
+
"only the config keys the caller's roles may read, before any value is returned",
|
|
24
|
+
},
|
|
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
|
+
},
|
|
20
32
|
handler: async (query, ctx) => {
|
|
21
33
|
const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
|
|
22
34
|
const registry = ctx.registry;
|
|
@@ -22,7 +22,14 @@ export const policyForQuery = defineQueryHandler({
|
|
|
22
22
|
schema: z.object({
|
|
23
23
|
entityName: z.string().min(1).max(100),
|
|
24
24
|
}),
|
|
25
|
-
access: {
|
|
25
|
+
access: {
|
|
26
|
+
openToAll: {
|
|
27
|
+
reason:
|
|
28
|
+
"any signed-in tenant member (and other features calling cross-feature) may " +
|
|
29
|
+
"resolve the retention policy for an entity name in their own tenant; the result " +
|
|
30
|
+
"carries only policy metadata, no PII",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
26
33
|
description:
|
|
27
34
|
"Resolves the effective retention policy for one entity name in the caller's tenant (keep-for duration plus delete or anonymize strategy) by layering the entity default, tenant preset and tenant override, so a forget flow or cleanup job knows how that data may be removed.",
|
|
28
35
|
handler: async (query, ctx): Promise<EffectiveRetentionPolicy> => {
|
|
@@ -65,7 +65,7 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
65
65
|
});
|
|
66
66
|
return { isSuccess: true, data: { sent: true } };
|
|
67
67
|
},
|
|
68
|
-
access: { openToAll:
|
|
68
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
69
69
|
}),
|
|
70
70
|
);
|
|
71
71
|
|
|
@@ -82,7 +82,7 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
82
82
|
});
|
|
83
83
|
return { isSuccess: true, data: { sent: true } };
|
|
84
84
|
},
|
|
85
|
-
access: { openToAll:
|
|
85
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
86
86
|
}),
|
|
87
87
|
);
|
|
88
88
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { access, isOpenToAllGranted, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
4
|
import { DELIVERY_LOG_SCREEN_ID } from "../constants";
|
|
5
5
|
import { createDeliveryFeature } from "../feature";
|
|
@@ -33,9 +33,7 @@ describe("delivery screens + handler access alignment", () => {
|
|
|
33
33
|
test("access option leaves the preferences handler openToAll — that's per-user, not an admin surface", () => {
|
|
34
34
|
const delivery = createDeliveryFeature({ access: access.systemAdmin });
|
|
35
35
|
const preferencesAccess = delivery.queryHandlers["preferences"]?.access;
|
|
36
|
-
expect(
|
|
37
|
-
preferencesAccess && "openToAll" in preferencesAccess && preferencesAccess.openToAll,
|
|
38
|
-
).toBe(true);
|
|
36
|
+
expect(preferencesAccess !== undefined && isOpenToAllGranted(preferencesAccess)).toBe(true);
|
|
39
37
|
});
|
|
40
38
|
|
|
41
39
|
test("boot-validates with a narrowed access option", () => {
|
|
@@ -101,7 +101,7 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
101
101
|
|
|
102
102
|
return { isSuccess: true, data: { assigned: true } };
|
|
103
103
|
},
|
|
104
|
-
access: { openToAll:
|
|
104
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
105
105
|
}),
|
|
106
106
|
);
|
|
107
107
|
|
|
@@ -124,7 +124,7 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
124
124
|
|
|
125
125
|
return { isSuccess: true, data: { sent: true } };
|
|
126
126
|
},
|
|
127
|
-
access: { openToAll:
|
|
127
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
128
128
|
}),
|
|
129
129
|
);
|
|
130
130
|
|
|
@@ -141,7 +141,7 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
141
141
|
title: z.string(),
|
|
142
142
|
body: z.string(),
|
|
143
143
|
}),
|
|
144
|
-
access: { openToAll:
|
|
144
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
145
145
|
handler: async (event, ctx) => {
|
|
146
146
|
const notify = ctx.notify as NotifyFn;
|
|
147
147
|
await notify(event.payload.notificationType, {
|
|
@@ -173,7 +173,7 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
173
173
|
|
|
174
174
|
return { isSuccess: true, data: { sent: true } };
|
|
175
175
|
},
|
|
176
|
-
access: { openToAll:
|
|
176
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
177
177
|
}),
|
|
178
178
|
);
|
|
179
179
|
});
|
|
@@ -206,7 +206,7 @@ const ticketFeature = defineFeature("tickets", (r) => {
|
|
|
206
206
|
status: z.string(),
|
|
207
207
|
}),
|
|
208
208
|
async (event, ctx) => ticketExecutor().create(event.payload, event.user, ctx.db),
|
|
209
|
-
{ access: { openToAll:
|
|
209
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
210
210
|
);
|
|
211
211
|
|
|
212
212
|
// Declarative: notify assignee when ticket is created with assigneeId
|
|
@@ -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
|
},
|
|
@@ -7,7 +7,19 @@ import { notificationPreferencesTable } from "../tables";
|
|
|
7
7
|
export const preferencesQuery = defineQueryHandler({
|
|
8
8
|
name: "preferences",
|
|
9
9
|
schema: z.object({}),
|
|
10
|
-
access: {
|
|
10
|
+
access: {
|
|
11
|
+
openToAll: {
|
|
12
|
+
reason:
|
|
13
|
+
"each signed-in user reads only their own notification preference rows; the " +
|
|
14
|
+
"query filters notificationPreferencesTable by the caller's own tenantId and userId",
|
|
15
|
+
},
|
|
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
|
+
},
|
|
11
23
|
description:
|
|
12
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.",
|
|
13
25
|
handler: async (query, ctx) => {
|
|
@@ -11,7 +11,13 @@ export const setPreferenceWrite = defineWriteHandler({
|
|
|
11
11
|
enabled: z.boolean(),
|
|
12
12
|
}),
|
|
13
13
|
// Every user manages their own preferences; tenant+user scoping is on the WHERE.
|
|
14
|
-
access: {
|
|
14
|
+
access: {
|
|
15
|
+
openToAll: {
|
|
16
|
+
reason:
|
|
17
|
+
"each signed-in user changes only their own notification preferences; the " +
|
|
18
|
+
"write is scoped to the caller's own tenantId and userId via assertTenantMatch",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
15
21
|
description:
|
|
16
22
|
"Enables or disables one notification type and channel combination for the calling user, where either side may be the wildcard * as a catch-all; use it when a user changes their own notification settings.",
|
|
17
23
|
handler: async (event, ctx) => {
|