@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.150.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 +7 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +1 -1
- package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +1 -1
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -2
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +6 -1
- package/src/auth-email-password/seeding.ts +1 -1
- package/src/auth-email-password/web/auth-gate.tsx +2 -0
- package/src/auth-email-password/web/login-screen.tsx +2 -0
- package/src/auth-mfa/__tests__/base32.test.ts +28 -0
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
- package/src/auth-mfa/__tests__/totp.test.ts +11 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
- package/src/auth-mfa/base32.ts +7 -1
- package/src/auth-mfa/constants.ts +14 -0
- package/src/auth-mfa/errors.ts +9 -16
- package/src/auth-mfa/feature.ts +4 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
- package/src/auth-mfa/handlers/verify.write.ts +26 -3
- package/src/auth-mfa/index.ts +0 -1
- package/src/auth-mfa/recovery-codes.ts +12 -2
- package/src/auth-mfa/schema/user-mfa.ts +3 -0
- package/src/auth-mfa/testing.ts +5 -0
- package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +37 -28
- package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
- package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
- package/src/config/handlers/cascade.query.ts +2 -1
- package/src/config/handlers/values.query.ts +2 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
- package/src/legal-pages/feature.ts +2 -1
- package/src/managed-pages/feature.ts +1 -6
- package/src/seo/feature.ts +1 -6
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
- package/src/sessions/__tests__/test-helpers.ts +1 -1
- package/src/sessions/constants.ts +5 -0
- package/src/sessions/feature.ts +67 -1
- package/src/sessions/handlers/detail.query.ts +36 -0
- package/src/sessions/i18n.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/system-query.ts +5 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
- package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
- package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
- package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
- package/src/user-profile/handlers/change-email.write.ts +2 -2
- package/src/auth-email-password/password-hashing.ts +0 -4
|
@@ -97,7 +97,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
97
97
|
{ slug: route.slug, lang: route.lang },
|
|
98
98
|
SYSTEM_TENANT_ID,
|
|
99
99
|
)) as TextBlockQueryResult;
|
|
100
|
-
} catch {
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(`legal-pages: text-content query failed for slug="${route.slug}"`, err);
|
|
101
102
|
return c.text("legal page unavailable", 503);
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
renderSafeMarkdown,
|
|
16
16
|
wrapInLayout,
|
|
17
17
|
} from "../page-render";
|
|
18
|
+
import type { SystemQueryFn } from "../shared";
|
|
18
19
|
import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding";
|
|
19
20
|
import { createBrandingQuery } from "./handlers/branding.query";
|
|
20
21
|
import { bySlugQuery } from "./handlers/by-slug.query";
|
|
@@ -38,12 +39,6 @@ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
|
38
39
|
// Handlers, symmetrisch zum legal-pages-Muster).
|
|
39
40
|
const BY_SLUG_QN = "managed-pages:query:by-slug";
|
|
40
41
|
|
|
41
|
-
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
|
|
42
|
-
// http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
|
|
43
|
-
// isn't part of the engine's public surface (only the httpRoute-
|
|
44
|
-
// registration types are; same convention as seo/feature.ts's FetchApp).
|
|
45
|
-
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
46
|
-
|
|
47
42
|
// Raw Handler-Return von bySlugQuery (published-only) — systemQuery dispatcht
|
|
48
43
|
// direkt gegen den Handler, keine `{data}`-Envelope wie am /api/query-Wire.
|
|
49
44
|
type BySlugQueryResult = {
|
package/src/seo/feature.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { LEGAL_ROUTES } from "../legal-pages";
|
|
8
8
|
import { cachedSecurePageResponse } from "../page-render";
|
|
9
|
+
import type { SystemQueryFn } from "../shared";
|
|
9
10
|
import { SEO_CONFIG_KEYS, SEO_DEFAULT_PATHS } from "./constants";
|
|
10
11
|
import { seoConfigQuery } from "./handlers/seo-config.query";
|
|
11
12
|
import { buildLlmsTxt } from "./llms-txt";
|
|
@@ -19,12 +20,6 @@ const SEO_CACHE = { kind: "revalidate", maxAgeSeconds: 300 } as const;
|
|
|
19
20
|
const MANAGED_PAGES_BY_TENANT_PUBLISHED_QN = "managed-pages:query:by-tenant-published";
|
|
20
21
|
const SEO_CONFIG_QUERY_QN = "seo:query:config";
|
|
21
22
|
|
|
22
|
-
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — just
|
|
23
|
-
// enough to force a specific tenant in-process. Not importing
|
|
24
|
-
// HttpRouteHandlerDeps itself: it isn't part of the engine's public surface
|
|
25
|
-
// (only the httpRoute-registration types are).
|
|
26
|
-
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
27
|
-
|
|
28
23
|
export type ManagedPagesDiscoveryOptions = {
|
|
29
24
|
/** Same per-host tenant resolver the app already passes to
|
|
30
25
|
* createManagedPagesFeature — needed to X-Tenant-scope the anonymous
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { createTenantFeature } from "../../tenant";
|
|
6
|
+
import { createUserFeature } from "../../user/feature";
|
|
7
|
+
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID } from "../constants";
|
|
8
|
+
import { createSessionsFeature } from "../feature";
|
|
9
|
+
|
|
10
|
+
describe("sessions screens + query access alignment (kumiko-framework#255)", () => {
|
|
11
|
+
const features = [
|
|
12
|
+
createConfigFeature(),
|
|
13
|
+
createUserFeature(),
|
|
14
|
+
createTenantFeature(),
|
|
15
|
+
createSessionsFeature(),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
test("boot-validates with session-list/session-detail screens registered", () => {
|
|
19
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("session-list is projectionList, session-detail is projectionDetail, both admin-gated", () => {
|
|
23
|
+
const sessions = createSessionsFeature();
|
|
24
|
+
const list = sessions.screens[SESSION_LIST_SCREEN_ID];
|
|
25
|
+
expect(list?.type).toBe("projectionList");
|
|
26
|
+
const detail = sessions.screens[SESSION_DETAIL_SCREEN_ID];
|
|
27
|
+
expect(detail?.type).toBe("projectionDetail");
|
|
28
|
+
for (const screen of [list, detail]) {
|
|
29
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
30
|
+
expect(screen.access.roles).toEqual(["TenantAdmin", "Admin", "SystemAdmin"]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("session-list row-action navigates to session-detail with entityId 'id'", () => {
|
|
36
|
+
const sessions = createSessionsFeature();
|
|
37
|
+
const list = sessions.screens[SESSION_LIST_SCREEN_ID];
|
|
38
|
+
if (list?.type !== "projectionList") throw new Error("expected projectionList");
|
|
39
|
+
const openAction = list.rowActions?.find((a) => a.id === "open");
|
|
40
|
+
if (openAction?.kind !== "navigate") throw new Error("expected a navigate rowAction");
|
|
41
|
+
expect(openAction.screen).toBe(SESSION_DETAIL_SCREEN_ID);
|
|
42
|
+
expect(openAction.entityId).toBe("id");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("sessions queries share the admin-or-higher access rule", () => {
|
|
46
|
+
const sessions = createSessionsFeature();
|
|
47
|
+
const roles = ["TenantAdmin", "Admin", "SystemAdmin"];
|
|
48
|
+
expect(rolesOf(sessions.queryHandlers["user-session:list"]?.access)).toEqual(roles);
|
|
49
|
+
expect(rolesOf(sessions.queryHandlers["user-session:detail"]?.access)).toEqual(roles);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -467,6 +467,50 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
467
467
|
// silent orderBy removal.
|
|
468
468
|
expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
|
|
469
469
|
});
|
|
470
|
+
|
|
471
|
+
// Single-row inspector backing the session-detail screen (kumiko-framework#255).
|
|
472
|
+
// Same access-gate as session:list (admin-or-higher); verifies field-shape,
|
|
473
|
+
// decryption of ip/userAgent, and the "unknown id" not-found path.
|
|
474
|
+
test("session:detail returns one decrypted row for admins, null for an unknown id", async () => {
|
|
475
|
+
const { userId: aliceId } = await h.seedUser("alice3@example.com", "pw-long-enough");
|
|
476
|
+
|
|
477
|
+
await updateRows(
|
|
478
|
+
stack.db,
|
|
479
|
+
tenantMembershipsTable,
|
|
480
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
481
|
+
{ userId: aliceId, tenantId: TENANT },
|
|
482
|
+
);
|
|
483
|
+
const aliceAsAdmin = await h.login("alice3@example.com", "pw-long-enough");
|
|
484
|
+
|
|
485
|
+
const res = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
486
|
+
type: SessionQueries.detail,
|
|
487
|
+
payload: { id: aliceAsAdmin.sid },
|
|
488
|
+
});
|
|
489
|
+
expect(res.status).toBe(200);
|
|
490
|
+
const body = (await res.json()) as {
|
|
491
|
+
data: { id: string; userId: string; revokedAt: string | null } | null;
|
|
492
|
+
};
|
|
493
|
+
expect(body.data?.id).toBe(aliceAsAdmin.sid);
|
|
494
|
+
expect(body.data?.userId).toBe(aliceId);
|
|
495
|
+
expect(body.data?.revokedAt).toBeNull();
|
|
496
|
+
|
|
497
|
+
const missing = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
498
|
+
type: SessionQueries.detail,
|
|
499
|
+
payload: { id: "00000000-0000-4000-8000-0000deadbeef" },
|
|
500
|
+
});
|
|
501
|
+
expect(missing.status).toBe(200);
|
|
502
|
+
const missingBody = (await missing.json()) as { data: unknown };
|
|
503
|
+
expect(missingBody.data).toBeNull();
|
|
504
|
+
|
|
505
|
+
// Plain User gets the same 403 as session:list — same access-gate.
|
|
506
|
+
await h.seedUser("bob3@example.com", "pw-long-enough");
|
|
507
|
+
const bob = await h.login("bob3@example.com", "pw-long-enough");
|
|
508
|
+
const asUser = await h.authedPost("/api/query", bob.token, {
|
|
509
|
+
type: SessionQueries.detail,
|
|
510
|
+
payload: { id: aliceAsAdmin.sid },
|
|
511
|
+
});
|
|
512
|
+
expect(asUser.status).toBe(403);
|
|
513
|
+
});
|
|
470
514
|
});
|
|
471
515
|
|
|
472
516
|
// Defense-in-depth: the sessionChecker refuses a live sid once the user it
|
|
@@ -12,7 +12,7 @@ import { expect } from "bun:test";
|
|
|
12
12
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
13
|
import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
14
14
|
import * as jose from "jose";
|
|
15
|
-
import { hashPassword } from "../../
|
|
15
|
+
import { hashPassword } from "../../shared";
|
|
16
16
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
17
17
|
import { UserHandlers } from "../../user";
|
|
18
18
|
|
|
@@ -17,6 +17,8 @@ export const SessionQueries = {
|
|
|
17
17
|
// Admin-scoped: all sessions in the caller's tenant (live + revoked).
|
|
18
18
|
// Tenant isolation comes from ctx.db; access-gate is admin-or-higher.
|
|
19
19
|
list: "sessions:query:user-session:list",
|
|
20
|
+
// Admin-scoped single-session inspector, backs the session-detail screen.
|
|
21
|
+
detail: "sessions:query:user-session:detail",
|
|
20
22
|
} as const;
|
|
21
23
|
|
|
22
24
|
export const SessionErrors = {
|
|
@@ -45,3 +47,6 @@ export const DEFAULT_SESSION_CACHE_TTL_MS = 60_000;
|
|
|
45
47
|
// longer so refresh can rotate the token without requiring a new password).
|
|
46
48
|
// MVP ships a single window; per-app overrides can come later.
|
|
47
49
|
export const DEFAULT_SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
export const SESSION_LIST_SCREEN_ID = "session-list" as const;
|
|
52
|
+
export const SESSION_DETAIL_SCREEN_ID = "session-detail" as const;
|
package/src/sessions/feature.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
defineFeature,
|
|
5
|
+
type FeatureDefinition,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
|
|
3
8
|
import { cleanupJob } from "./handlers/cleanup.job";
|
|
9
|
+
import { detailQuery } from "./handlers/detail.query";
|
|
4
10
|
import { listQuery } from "./handlers/list.query";
|
|
5
11
|
import { mineQuery } from "./handlers/mine.query";
|
|
6
12
|
import { revokeWrite } from "./handlers/revoke.write";
|
|
7
13
|
import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
8
14
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
15
|
+
import { SESSIONS_I18N } from "./i18n";
|
|
9
16
|
import { userSessionEntity } from "./schema/user-session";
|
|
10
17
|
import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
|
|
11
18
|
|
|
@@ -105,6 +112,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
105
112
|
const queries = {
|
|
106
113
|
mine: r.queryHandler(mineQuery),
|
|
107
114
|
list: r.queryHandler(listQuery),
|
|
115
|
+
detail: r.queryHandler(detailQuery),
|
|
108
116
|
};
|
|
109
117
|
|
|
110
118
|
// Retention: chunked DELETE of expired/revoked rows. Manual trigger
|
|
@@ -143,6 +151,64 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
143
151
|
autoRevoke ??= revoker;
|
|
144
152
|
};
|
|
145
153
|
|
|
154
|
+
r.translations({ keys: SESSIONS_I18N });
|
|
155
|
+
|
|
156
|
+
const listAccess = { roles: access.admin };
|
|
157
|
+
|
|
158
|
+
r.screen({
|
|
159
|
+
id: SESSION_LIST_SCREEN_ID,
|
|
160
|
+
type: "projectionList",
|
|
161
|
+
query: SessionQueries.list,
|
|
162
|
+
columns: [
|
|
163
|
+
{ field: "id", label: "sessions.list.col.id" },
|
|
164
|
+
{ field: "userId", label: "sessions.list.col.userId" },
|
|
165
|
+
{ field: "createdAt", label: "sessions.list.col.createdAt" },
|
|
166
|
+
{ field: "expiresAt", label: "sessions.list.col.expiresAt" },
|
|
167
|
+
{ field: "revokedAt", label: "sessions.list.col.revokedAt" },
|
|
168
|
+
],
|
|
169
|
+
rowActions: [
|
|
170
|
+
{
|
|
171
|
+
kind: "navigate",
|
|
172
|
+
id: "open",
|
|
173
|
+
label: "sessions.list.action.open",
|
|
174
|
+
screen: SESSION_DETAIL_SCREEN_ID,
|
|
175
|
+
entityId: "id",
|
|
176
|
+
rowClick: true,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
access: listAccess,
|
|
180
|
+
});
|
|
181
|
+
r.screen({
|
|
182
|
+
id: SESSION_DETAIL_SCREEN_ID,
|
|
183
|
+
type: "projectionDetail",
|
|
184
|
+
query: SessionQueries.detail,
|
|
185
|
+
listScreenId: SESSION_LIST_SCREEN_ID,
|
|
186
|
+
layout: {
|
|
187
|
+
sections: [
|
|
188
|
+
{
|
|
189
|
+
fields: ["id", "userId", "createdAt", "expiresAt", "revokedAt", "ip", "userAgent"],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
fieldLabels: {
|
|
194
|
+
id: "sessions.detail.field.id",
|
|
195
|
+
userId: "sessions.detail.field.userId",
|
|
196
|
+
createdAt: "sessions.detail.field.createdAt",
|
|
197
|
+
expiresAt: "sessions.detail.field.expiresAt",
|
|
198
|
+
revokedAt: "sessions.detail.field.revokedAt",
|
|
199
|
+
ip: "sessions.detail.field.ip",
|
|
200
|
+
userAgent: "sessions.detail.field.userAgent",
|
|
201
|
+
},
|
|
202
|
+
access: listAccess,
|
|
203
|
+
});
|
|
204
|
+
r.nav({
|
|
205
|
+
id: "session-list",
|
|
206
|
+
label: "sessions:nav.sessionList",
|
|
207
|
+
icon: "list",
|
|
208
|
+
screen: "sessions:screen:session-list",
|
|
209
|
+
order: 10,
|
|
210
|
+
});
|
|
211
|
+
|
|
146
212
|
return { handlers, queries, bindAutoRevokeOnPasswordChange };
|
|
147
213
|
});
|
|
148
214
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { decryptStoredPii } from "../../shared";
|
|
5
|
+
import { userSessionTable } from "../schema/user-session";
|
|
6
|
+
|
|
7
|
+
// Admin single-session inspector — mirrors list.query's decrypt handling for
|
|
8
|
+
// the one-row case. ctx.db (TenantDb) applies tenant-scoping automatically.
|
|
9
|
+
export const detailQuery = defineQueryHandler({
|
|
10
|
+
name: "user-session:detail",
|
|
11
|
+
schema: z.object({ id: z.uuid() }),
|
|
12
|
+
access: { roles: access.admin },
|
|
13
|
+
handler: async (query, ctx) => {
|
|
14
|
+
const row = await fetchOne<{
|
|
15
|
+
id: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
createdAt: unknown;
|
|
18
|
+
expiresAt: unknown;
|
|
19
|
+
revokedAt: unknown;
|
|
20
|
+
ip: string | null;
|
|
21
|
+
userAgent: string | null;
|
|
22
|
+
}>(ctx.db, userSessionTable, { id: query.payload.id });
|
|
23
|
+
if (!row) return null;
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
userId: row.userId,
|
|
27
|
+
createdAt: row.createdAt,
|
|
28
|
+
expiresAt: row.expiresAt,
|
|
29
|
+
revokedAt: row.revokedAt,
|
|
30
|
+
ip: row.ip ? await decryptStoredPii(row.ip, "sessions:detail") : row.ip,
|
|
31
|
+
userAgent: row.userAgent
|
|
32
|
+
? await decryptStoredPii(row.userAgent, "sessions:detail")
|
|
33
|
+
: row.userAgent,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Server + client i18n for the sessions operator screens.
|
|
3
|
+
|
|
4
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
5
|
+
|
|
6
|
+
export const SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
+
"screen:session-list.title": { de: "Sitzungen", en: "Sessions" },
|
|
8
|
+
"screen:session-detail.title": { de: "Sitzung", en: "Session" },
|
|
9
|
+
"sessions:nav.sessionList": { de: "Sitzungen", en: "Sessions" },
|
|
10
|
+
"sessions.list.col.id": { de: "ID", en: "ID" },
|
|
11
|
+
"sessions.list.col.userId": { de: "User-ID", en: "User ID" },
|
|
12
|
+
"sessions.list.col.createdAt": { de: "Erstellt", en: "Created" },
|
|
13
|
+
"sessions.list.col.expiresAt": { de: "Läuft ab", en: "Expires" },
|
|
14
|
+
"sessions.list.col.revokedAt": { de: "Widerrufen", en: "Revoked" },
|
|
15
|
+
"sessions.list.action.open": { de: "Details", en: "Details" },
|
|
16
|
+
"sessions.detail.field.id": { de: "ID", en: "ID" },
|
|
17
|
+
"sessions.detail.field.userId": { de: "User-ID", en: "User ID" },
|
|
18
|
+
"sessions.detail.field.createdAt": { de: "Erstellt", en: "Created" },
|
|
19
|
+
"sessions.detail.field.expiresAt": { de: "Läuft ab", en: "Expires" },
|
|
20
|
+
"sessions.detail.field.revokedAt": { de: "Widerrufen", en: "Revoked" },
|
|
21
|
+
"sessions.detail.field.ip": { de: "IP-Adresse", en: "IP address" },
|
|
22
|
+
"sessions.detail.field.userAgent": { de: "User-Agent", en: "User agent" },
|
|
23
|
+
};
|
package/src/shared/index.ts
CHANGED
|
@@ -9,4 +9,5 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
|
|
|
9
9
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write";
|
|
10
10
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
11
11
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
12
|
+
export type { SystemQueryFn } from "./system-query";
|
|
12
13
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
|
|
2
|
+
// http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
|
|
3
|
+
// isn't part of the engine's public surface (only the httpRoute-
|
|
4
|
+
// registration types are).
|
|
5
|
+
export type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
15
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
16
16
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
18
17
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
19
18
|
import { createConfigFeature } from "../../config";
|
|
20
19
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -23,6 +22,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
|
|
|
23
22
|
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
24
23
|
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
25
24
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
25
|
+
import { hashPassword } from "../../shared";
|
|
26
26
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
27
27
|
import { createUserFeature } from "../../user/feature";
|
|
28
28
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// #1057 regression: anonymous GDPR magic-link download under
|
|
2
|
+
// `resolverTrust: "authoritative"` WITHOUT `defaultTenantId` (publicstatus's
|
|
3
|
+
// real prod config — Host-Resolver-only, no single-tenant fallback).
|
|
4
|
+
//
|
|
5
|
+
// The tenantResolver derives the ambient tenant from the request Host — here
|
|
6
|
+
// it always resolves to `hostTenant`, DIFFERENT from the tenant the export
|
|
7
|
+
// job actually belongs to (`jobTenant`). Before the fix, `download-by-token`
|
|
8
|
+
// resolved the file-storage-provider *selection* via `ctx.config`, which is
|
|
9
|
+
// bound to the ambient (resolved) tenant — `hostTenant` has no provider
|
|
10
|
+
// configured at all here, so the download 500s even though the token/job
|
|
11
|
+
// themselves are perfectly valid. The fix resolves the provider explicitly
|
|
12
|
+
// for `jobRow.requestedFromTenantId` instead.
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createInMemoryFileProvider,
|
|
19
|
+
type FileStorageProvider,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
testTenantId,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
unsafePushTables,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
30
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
31
|
+
import {
|
|
32
|
+
createComplianceProfilesFeature,
|
|
33
|
+
tenantComplianceProfileEntity,
|
|
34
|
+
} from "../../compliance-profiles";
|
|
35
|
+
import { createConfigFeature } from "../../config";
|
|
36
|
+
import { ConfigHandlers } from "../../config/constants";
|
|
37
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
38
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
39
|
+
import { configValuesTable } from "../../config/table";
|
|
40
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
41
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
42
|
+
import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
|
|
43
|
+
import { createSessionsFeature } from "../../sessions";
|
|
44
|
+
import { createUserFeature } from "../../user";
|
|
45
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
46
|
+
import { runExportJobs } from "../run-export-jobs";
|
|
47
|
+
import { exportDownloadTokenEntity } from "../schema/download-token";
|
|
48
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
49
|
+
|
|
50
|
+
const jobTenant = testTenantId(1);
|
|
51
|
+
const hostTenant = testTenantId(2);
|
|
52
|
+
const jobTenantAdmin = createTestUser({ id: 99, tenantId: jobTenant, roles: ["TenantAdmin"] });
|
|
53
|
+
const aliceUser = createTestUser({ id: 42, tenantId: jobTenant, roles: ["Member"] });
|
|
54
|
+
|
|
55
|
+
const providerPerTenant = new Map<string, ReturnType<typeof createInMemoryFileProvider>>();
|
|
56
|
+
function buildProvider(tenantId: string): Promise<FileStorageProvider> {
|
|
57
|
+
let p = providerPerTenant.get(tenantId);
|
|
58
|
+
if (!p) {
|
|
59
|
+
p = createInMemoryFileProvider();
|
|
60
|
+
providerPerTenant.set(tenantId, p);
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve(p);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let stack: TestStack;
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
69
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
70
|
+
|
|
71
|
+
stack = await setupTestStack({
|
|
72
|
+
features: [
|
|
73
|
+
createConfigFeature(),
|
|
74
|
+
createUserFeature(),
|
|
75
|
+
createDataRetentionFeature(),
|
|
76
|
+
createComplianceProfilesFeature(),
|
|
77
|
+
fileFoundationFeature,
|
|
78
|
+
fileProviderInMemoryFeature,
|
|
79
|
+
createSessionsFeature(),
|
|
80
|
+
createUserDataRightsFeature(),
|
|
81
|
+
],
|
|
82
|
+
extraContext: ({ registry }) => ({
|
|
83
|
+
configResolver: resolver,
|
|
84
|
+
configEncryption: encryption,
|
|
85
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
86
|
+
}),
|
|
87
|
+
// publicstatus prod: Host-Resolver-only, NO defaultTenantId. Resolver
|
|
88
|
+
// always answers `hostTenant` here — jobTenant never appears as the
|
|
89
|
+
// ambient/resolved tenant, only ever as jobRow.requestedFromTenantId.
|
|
90
|
+
anonymousAccess: {
|
|
91
|
+
tenantResolver: () => hostTenant,
|
|
92
|
+
resolverTrust: "authoritative",
|
|
93
|
+
tenantExists: async (id) => id === jobTenant || id === hostTenant,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await unsafeCreateEntityTable(stack.db, exportJobEntity);
|
|
97
|
+
await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
|
|
98
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
99
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
100
|
+
await createEventsTable(stack.db);
|
|
101
|
+
await asRawClient(stack.db).unsafe(`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
103
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
104
|
+
tenant_id UUID NOT NULL,
|
|
105
|
+
user_id TEXT NOT NULL,
|
|
106
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
108
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
109
|
+
inserted_by_id TEXT,
|
|
110
|
+
modified_by_id TEXT,
|
|
111
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
112
|
+
deleted_at TIMESTAMPTZ,
|
|
113
|
+
deleted_by_id TEXT,
|
|
114
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
115
|
+
UNIQUE(user_id, tenant_id)
|
|
116
|
+
)
|
|
117
|
+
`);
|
|
118
|
+
|
|
119
|
+
// Only jobTenant gets a provider configured — hostTenant deliberately has
|
|
120
|
+
// NONE, so a pre-fix ambient-tenant read would hard-fail with "no provider
|
|
121
|
+
// selected", not just pick the wrong one.
|
|
122
|
+
await stack.http.writeOk(
|
|
123
|
+
ConfigHandlers.set,
|
|
124
|
+
{ key: "file-foundation:config:provider", value: "inmemory" },
|
|
125
|
+
jobTenantAdmin,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
await stack.cleanup();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("download-by-token under resolverTrust: authoritative, no defaultTenantId", () => {
|
|
134
|
+
test("magic-link download succeeds via the job's own tenant, not the Host-resolved ambient tenant", async () => {
|
|
135
|
+
const requestRes = await stack.http.writeOk<{ jobId: string }>(
|
|
136
|
+
"user-data-rights:write:request-export",
|
|
137
|
+
{},
|
|
138
|
+
aliceUser,
|
|
139
|
+
);
|
|
140
|
+
const jobId = requestRes.jobId;
|
|
141
|
+
const provider = await buildProvider(jobTenant);
|
|
142
|
+
await provider.write(`${jobTenant}/exports/${jobId}.zip`, new Uint8Array([1, 2, 3]));
|
|
143
|
+
|
|
144
|
+
const result = await runExportJobs({
|
|
145
|
+
db: stack.db,
|
|
146
|
+
registry: stack.registry,
|
|
147
|
+
buildStorageProvider: buildProvider,
|
|
148
|
+
now: getTemporal().Now.instant(),
|
|
149
|
+
});
|
|
150
|
+
const plainToken = result.tokenByJobId.get(jobId);
|
|
151
|
+
if (!plainToken) {
|
|
152
|
+
throw new Error("token-create failed in worker run");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const res = await stack.app.fetch(
|
|
156
|
+
new Request(`http://test/user-export/by-token?token=${plainToken}`, { method: "GET" }),
|
|
157
|
+
);
|
|
158
|
+
expect(res.status).toBe(302);
|
|
159
|
+
const location = res.headers.get("location");
|
|
160
|
+
expect(location).toMatch(/^memory:\/\//);
|
|
161
|
+
expect(location).toContain(`${jobTenant}/exports/${jobId}.zip`);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -559,4 +559,43 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
|
|
|
559
559
|
);
|
|
560
560
|
expect(result.url).toMatch(/^memory:\/\//);
|
|
561
561
|
});
|
|
562
|
+
|
|
563
|
+
test("cross-tenant same-user: provider is resolved for the job's tenant, not the ambient session tenant", async () => {
|
|
564
|
+
// #1057 regression: createFileProviderForTenant used to read the
|
|
565
|
+
// provider *selection* from the ambient session's ctx.config — here
|
|
566
|
+
// Tenant B (Alice's session tenant) — instead of the job's own
|
|
567
|
+
// Tenant A. Give Tenant A a provider WITHOUT getSignedUrl while
|
|
568
|
+
// Tenant B keeps "inmemory" (default from beforeEach): if the bug
|
|
569
|
+
// resurfaces, the wrong (Tenant B) provider is picked and the
|
|
570
|
+
// request succeeds with 200 instead of 422.
|
|
571
|
+
const { jobId } = await seedDoneJobWithToken();
|
|
572
|
+
await stack.http.writeOk(
|
|
573
|
+
ConfigHandlers.set,
|
|
574
|
+
{ key: "file-foundation:config:provider", value: "no-signed-url" },
|
|
575
|
+
tenantAdmin, // Tenant A admin
|
|
576
|
+
);
|
|
577
|
+
const aliceFromTenantB = createTestUser({
|
|
578
|
+
id: 42,
|
|
579
|
+
tenantId: tenantB,
|
|
580
|
+
roles: ["Member"],
|
|
581
|
+
});
|
|
582
|
+
await asRawClient(stack.db).unsafe(
|
|
583
|
+
`
|
|
584
|
+
INSERT INTO read_tenant_memberships (tenant_id, user_id)
|
|
585
|
+
VALUES ($1, $2)
|
|
586
|
+
ON CONFLICT (user_id, tenant_id) DO NOTHING
|
|
587
|
+
`,
|
|
588
|
+
[tenantB, String(aliceUser.id)],
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const res = await stack.http.query(
|
|
592
|
+
"user-data-rights:query:download-by-job",
|
|
593
|
+
{ jobId },
|
|
594
|
+
aliceFromTenantB,
|
|
595
|
+
);
|
|
596
|
+
expect(res.status).toBe(422);
|
|
597
|
+
const body = (await res.json()) as { error: { code: string; i18nKey: string } };
|
|
598
|
+
expect(body.error.code).toBe("unprocessable");
|
|
599
|
+
expect(body.error.i18nKey).toBe("userDataRights.errors.download.signedUrlNotSupported");
|
|
600
|
+
});
|
|
562
601
|
});
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
47
47
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
48
48
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
49
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
50
49
|
import {
|
|
51
50
|
createComplianceProfilesFeature,
|
|
52
51
|
tenantComplianceProfileEntity,
|
|
@@ -60,6 +59,7 @@ import { createSessionsFeature } from "../../sessions";
|
|
|
60
59
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
61
60
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
62
61
|
import { sessionCallbacksFromLateBound } from "../../sessions/testing";
|
|
62
|
+
import { hashPassword } from "../../shared";
|
|
63
63
|
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
64
64
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
65
65
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
32
32
|
import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
|
|
33
33
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
34
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
35
34
|
import {
|
|
36
35
|
createComplianceProfilesFeature,
|
|
37
36
|
tenantComplianceProfileEntity,
|
|
@@ -46,6 +45,7 @@ import { SessionHandlers } from "../../sessions/constants";
|
|
|
46
45
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
47
46
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
48
47
|
import { sessionCallbacksFromLateBound } from "../../sessions/testing";
|
|
48
|
+
import { hashPassword } from "../../shared";
|
|
49
49
|
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
50
50
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
51
51
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -29,8 +29,8 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
29
29
|
import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
31
31
|
import { z } from "zod";
|
|
32
|
-
import { createFileProviderForTenant } from "../../file-foundation";
|
|
33
32
|
import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
|
|
33
|
+
import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
|
|
34
34
|
import { exportDownloadTokensTable } from "../schema/download-token";
|
|
35
35
|
import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
|
|
36
36
|
|
|
@@ -127,7 +127,10 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Provider bound explicitly to the job's tenant — NOT the ambient
|
|
131
|
+
// session tenant (cross-tenant-same-user: job.requestedFromTenantId
|
|
132
|
+
// can differ from query.user.tenantId).
|
|
133
|
+
const provider = await resolveTenantFileProvider(
|
|
131
134
|
ctx,
|
|
132
135
|
jobRow.requestedFromTenantId,
|
|
133
136
|
"user-data-rights:query:download-by-job",
|