@cosmicdrift/kumiko-bundled-features 0.258.0 → 0.259.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 +10 -10
- package/src/__tests__/account-security-composition.boot.test.ts +83 -0
- package/src/__tests__/account-security-composition.integration.test.ts +168 -0
- package/src/__tests__/account-security-fixture.ts +52 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/constants.ts +2 -0
- package/src/auth-mfa/feature.ts +3 -1
- package/src/auth-mfa/i18n.ts +10 -0
- package/src/auth-mfa/index.ts +2 -0
- package/src/auth-mfa/screens.ts +54 -2
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +8 -2
- package/src/notes-history/entity.ts +6 -1
- package/src/notes-history-user-data/__tests__/mention-forget.integration.test.ts +8 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +6 -5
- package/src/sessions/changes.json +7 -0
- package/src/sessions/constants.ts +1 -0
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/handlers/mine.query.ts +20 -4
- package/src/sessions/i18n.ts +10 -0
- package/src/sessions/index.ts +1 -0
- package/src/sessions/screens.ts +55 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.259.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -129,16 +129,17 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.259.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.259.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.259.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.259.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.259.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.259.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
141
141
|
"@types/nodemailer": "^8.0.0",
|
|
142
|
+
"@types/qrcode": "^1.5.5",
|
|
142
143
|
"clsx": "^2.1.1",
|
|
143
144
|
"imapflow": "^1.3.3",
|
|
144
145
|
"lucide-react": "^1.14.0",
|
|
@@ -162,8 +163,7 @@
|
|
|
162
163
|
],
|
|
163
164
|
"devDependencies": {
|
|
164
165
|
"@testing-library/user-event": "^14.6.1",
|
|
165
|
-
"@
|
|
166
|
-
"@cosmicdrift/kumiko-locale-
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.258.0"
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.259.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.259.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
5
|
+
import { authFoundationFeature } from "../auth-foundation";
|
|
6
|
+
import {
|
|
7
|
+
AuthMfaHandlers,
|
|
8
|
+
createAuthMfaFeature,
|
|
9
|
+
MFA_DISABLE_SCREEN_ID,
|
|
10
|
+
MFA_ENABLE_SCREEN_ID,
|
|
11
|
+
MFA_REGENERATE_RECOVERY_SCREEN_ID,
|
|
12
|
+
} from "../auth-mfa";
|
|
13
|
+
import { createConfigFeature } from "../config/feature";
|
|
14
|
+
import { createPersonalAccessTokensFeature } from "../personal-access-tokens";
|
|
15
|
+
import { createSessionsFeature, SESSION_MINE_SCREEN_ID, SessionHandlers } from "../sessions";
|
|
16
|
+
import { createTenantFeature } from "../tenant";
|
|
17
|
+
import { createUserFeature } from "../user/feature";
|
|
18
|
+
import { accountSecurityFeature, testAuthMfaOptions } from "./account-security-fixture";
|
|
19
|
+
|
|
20
|
+
function bootFeatures() {
|
|
21
|
+
return [
|
|
22
|
+
createConfigFeature(),
|
|
23
|
+
createUserFeature(),
|
|
24
|
+
createTenantFeature(),
|
|
25
|
+
authFoundationFeature,
|
|
26
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
27
|
+
createSessionsFeature(),
|
|
28
|
+
createAuthMfaFeature(testAuthMfaOptions),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("account-security composed from bundled screens (fw#2841)", () => {
|
|
33
|
+
// auth-mfa's user-mfa entity has encrypted fields — validateBoot probes for a cipher.
|
|
34
|
+
beforeAll(() => configureEntityFieldEncryption(createTestEnvelopeCipher()));
|
|
35
|
+
afterAll(() => configureEntityFieldEncryption(undefined));
|
|
36
|
+
|
|
37
|
+
test("boot-validates as a dashboard of screen panels", () => {
|
|
38
|
+
expect(() => validateBoot([...bootFeatures(), accountSecurityFeature])).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the self-service screens are open to every signed-in user", () => {
|
|
42
|
+
const [, , , , , sessions, mfa] = bootFeatures();
|
|
43
|
+
const screens = [
|
|
44
|
+
sessions?.screens[SESSION_MINE_SCREEN_ID],
|
|
45
|
+
mfa?.screens[MFA_ENABLE_SCREEN_ID],
|
|
46
|
+
mfa?.screens[MFA_DISABLE_SCREEN_ID],
|
|
47
|
+
mfa?.screens[MFA_REGENERATE_RECOVERY_SCREEN_ID],
|
|
48
|
+
];
|
|
49
|
+
for (const screen of screens) {
|
|
50
|
+
expect(screen?.access).toEqual({ openToAll: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("MFA disable and regenerate-recovery dispatch their handlers with a possession code", () => {
|
|
55
|
+
const mfa = bootFeatures()[6];
|
|
56
|
+
const disable = mfa?.screens[MFA_DISABLE_SCREEN_ID];
|
|
57
|
+
if (disable?.type !== "actionForm") throw new Error("expected an actionForm");
|
|
58
|
+
expect(disable.handler).toBe(AuthMfaHandlers.disable);
|
|
59
|
+
expect(Object.keys(disable.fields)).toEqual(["code"]);
|
|
60
|
+
|
|
61
|
+
const regenerate = mfa?.screens[MFA_REGENERATE_RECOVERY_SCREEN_ID];
|
|
62
|
+
if (regenerate?.type !== "secretMint") throw new Error("expected a secretMint");
|
|
63
|
+
expect(regenerate.handler).toBe(AuthMfaHandlers.regenerateRecovery);
|
|
64
|
+
expect(Object.keys(regenerate.fields)).toEqual(["code"]);
|
|
65
|
+
expect(regenerate.reveal.fields.map((f) => f.field)).toEqual(["recoveryCodes"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("my-sessions hides revoke on the current session and offers revoke-all-others", () => {
|
|
69
|
+
const sessions = bootFeatures()[5];
|
|
70
|
+
const mine = sessions?.screens[SESSION_MINE_SCREEN_ID];
|
|
71
|
+
if (mine?.type !== "projectionList") throw new Error("expected a projectionList");
|
|
72
|
+
const revoke = mine.rowActions?.find((a) => a.id === "revoke");
|
|
73
|
+
if (revoke === undefined || revoke.kind !== "writeHandler") {
|
|
74
|
+
throw new Error("expected a writeHandler revoke action");
|
|
75
|
+
}
|
|
76
|
+
expect(revoke.handler).toBe(SessionHandlers.revoke);
|
|
77
|
+
expect(revoke.visible).toEqual({ field: "current", eq: false });
|
|
78
|
+
const revokeAllOthers = mine.toolbarActions?.find((a) => a.id === "revoke-all-others");
|
|
79
|
+
expect(revokeAllOthers?.kind === "writeHandler" && revokeAllOthers.handler).toBe(
|
|
80
|
+
SessionHandlers.revokeAllOthers,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { buildAppSchema, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
|
+
import {
|
|
6
|
+
setupTestStack,
|
|
7
|
+
type TestStack,
|
|
8
|
+
testTenantId,
|
|
9
|
+
unsafeCreateEntityTable,
|
|
10
|
+
unsafePushTables,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
+
import {
|
|
13
|
+
createLateBoundHolder,
|
|
14
|
+
createTestEnvelopeCipher,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
16
|
+
import { AuthHandlers } from "../auth-email-password/constants";
|
|
17
|
+
import { createAuthEmailPasswordFeature } from "../auth-email-password/feature";
|
|
18
|
+
import { authFoundationFeature } from "../auth-foundation";
|
|
19
|
+
import { createAuthMfaFeature } from "../auth-mfa";
|
|
20
|
+
import { userMfaEntity } from "../auth-mfa/schema/user-mfa";
|
|
21
|
+
import { createConfigFeature } from "../config";
|
|
22
|
+
import { createConfigResolver } from "../config/resolver";
|
|
23
|
+
import { configValuesTable } from "../config/table";
|
|
24
|
+
import { createSessionsFeature } from "../sessions";
|
|
25
|
+
import { makeSessionHelpers } from "../sessions/__tests__/test-helpers";
|
|
26
|
+
import { userSessionEntity } from "../sessions/schema/user-session";
|
|
27
|
+
import { createSessionCallbacks, type SessionCallbacks } from "../sessions/session-callbacks";
|
|
28
|
+
import { sessionCallbacksFromLateBound } from "../sessions/testing";
|
|
29
|
+
import { createTenantFeature } from "../tenant";
|
|
30
|
+
import { tenantMembershipsTable } from "../tenant/membership-table";
|
|
31
|
+
import { tenantEntity } from "../tenant/schema/tenant";
|
|
32
|
+
import { createUserFeature } from "../user/feature";
|
|
33
|
+
import { userEntity } from "../user/schema/user";
|
|
34
|
+
import {
|
|
35
|
+
ACCOUNT_SECURITY_SCREEN_ID,
|
|
36
|
+
accountSecurityFeature,
|
|
37
|
+
mfaStatusVisibility,
|
|
38
|
+
testAuthMfaOptions,
|
|
39
|
+
} from "./account-security-fixture";
|
|
40
|
+
|
|
41
|
+
type PanelQueryRow = { readonly id: string; readonly current: boolean };
|
|
42
|
+
|
|
43
|
+
let stack: TestStack;
|
|
44
|
+
let h: ReturnType<typeof makeSessionHelpers>;
|
|
45
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
46
|
+
const TENANT: TenantId = testTenantId(1);
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
const encryption = createTestEnvelopeCipher();
|
|
50
|
+
configureEntityFieldEncryption(encryption);
|
|
51
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
52
|
+
stack = await setupTestStack({
|
|
53
|
+
features: [
|
|
54
|
+
createConfigFeature(),
|
|
55
|
+
createUserFeature(),
|
|
56
|
+
createTenantFeature(),
|
|
57
|
+
createAuthEmailPasswordFeature(),
|
|
58
|
+
authFoundationFeature,
|
|
59
|
+
createSessionsFeature(),
|
|
60
|
+
createAuthMfaFeature(testAuthMfaOptions),
|
|
61
|
+
accountSecurityFeature,
|
|
62
|
+
],
|
|
63
|
+
extraContext: {
|
|
64
|
+
configResolver: createConfigResolver({ cipher: encryption }),
|
|
65
|
+
configEncryption: encryption,
|
|
66
|
+
},
|
|
67
|
+
authConfig: {
|
|
68
|
+
...bound.asAuthConfig(),
|
|
69
|
+
membershipQuery: "tenant:query:memberships",
|
|
70
|
+
loginHandler: AuthHandlers.login,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
74
|
+
h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
|
|
75
|
+
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
78
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
80
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
81
|
+
await createEventsTable(stack.db);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await stack.cleanup();
|
|
86
|
+
configureEntityFieldEncryption(undefined);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function accountSecurityScreenPanels() {
|
|
90
|
+
const app = buildAppSchema(stack.registry);
|
|
91
|
+
const screen = app.features
|
|
92
|
+
.find((f) => f.featureName === "account-security")
|
|
93
|
+
?.screens.find((s) => s.id === ACCOUNT_SECURITY_SCREEN_ID);
|
|
94
|
+
if (screen?.type !== "dashboard") throw new Error("expected the account-security dashboard");
|
|
95
|
+
return screen.panels;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function embeddedScreen(qn: string) {
|
|
99
|
+
const [featureName, , screenId] = qn.split(":");
|
|
100
|
+
const screen = buildAppSchema(stack.registry)
|
|
101
|
+
.features.find((f) => f.featureName === featureName)
|
|
102
|
+
?.screens.find((s) => s.id === screenId);
|
|
103
|
+
if (screen === undefined) throw new Error(`embedded screen ${qn} missing from app schema`);
|
|
104
|
+
return screen;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("account-security dashboard on a real stack", () => {
|
|
108
|
+
test("ships its screen panels in the client app schema, each resolving to a wired screen", () => {
|
|
109
|
+
const panels = accountSecurityScreenPanels();
|
|
110
|
+
const targets = panels.flatMap((p) => (p.kind === "screen" ? [p.screen] : []));
|
|
111
|
+
expect(targets).toEqual([
|
|
112
|
+
"auth-mfa:screen:auth-mfa-enable",
|
|
113
|
+
"auth-mfa:screen:auth-mfa-regenerate-recovery",
|
|
114
|
+
"auth-mfa:screen:auth-mfa-disable",
|
|
115
|
+
"sessions:screen:my-sessions",
|
|
116
|
+
]);
|
|
117
|
+
for (const qn of targets) expect(embeddedScreen(qn).access).toEqual({ openToAll: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a plain User gets the flat visibleWhen record and their own sessions over HTTP", async () => {
|
|
121
|
+
await h.seedUser("dash@example.com", "pw-long-enough");
|
|
122
|
+
const other = await h.login("dash@example.com", "pw-long-enough");
|
|
123
|
+
const current = await h.login("dash@example.com", "pw-long-enough");
|
|
124
|
+
|
|
125
|
+
const statusRes = await h.authedPost("/api/query", current.token, {
|
|
126
|
+
type: mfaStatusVisibility.query,
|
|
127
|
+
payload: {},
|
|
128
|
+
});
|
|
129
|
+
expect(statusRes.status).toBe(200);
|
|
130
|
+
const status = (await statusRes.json()) as { data: Record<string, unknown> };
|
|
131
|
+
expect(status.data[mfaStatusVisibility.field]).toBe(false);
|
|
132
|
+
|
|
133
|
+
const sessionsScreen = embeddedScreen("sessions:screen:my-sessions");
|
|
134
|
+
if (sessionsScreen.type !== "projectionList") throw new Error("expected a projectionList");
|
|
135
|
+
const listRes = await h.authedPost("/api/query", current.token, {
|
|
136
|
+
type: sessionsScreen.query,
|
|
137
|
+
payload: { limit: 50 },
|
|
138
|
+
});
|
|
139
|
+
expect(listRes.status).toBe(200);
|
|
140
|
+
const list = (await listRes.json()) as { data: { rows: PanelQueryRow[] } };
|
|
141
|
+
expect(list.data.rows.map((r) => r.id).sort()).toEqual([current.sid, other.sid].sort());
|
|
142
|
+
expect(list.data.rows.find((r) => r.current)?.id).toBe(current.sid);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the embedded list's revoke-all-others toolbar action leaves only the current session", async () => {
|
|
146
|
+
await h.seedUser("dash-revoke@example.com", "pw-long-enough");
|
|
147
|
+
await h.login("dash-revoke@example.com", "pw-long-enough");
|
|
148
|
+
const current = await h.login("dash-revoke@example.com", "pw-long-enough");
|
|
149
|
+
|
|
150
|
+
const sessionsScreen = embeddedScreen("sessions:screen:my-sessions");
|
|
151
|
+
if (sessionsScreen.type !== "projectionList") throw new Error("expected a projectionList");
|
|
152
|
+
const action = sessionsScreen.toolbarActions?.find((a) => a.id === "revoke-all-others");
|
|
153
|
+
if (action?.kind !== "writeHandler") throw new Error("expected a writeHandler toolbar action");
|
|
154
|
+
|
|
155
|
+
const revokeRes = await h.authedPost("/api/write", current.token, {
|
|
156
|
+
type: action.handler,
|
|
157
|
+
payload: action.payload ?? {},
|
|
158
|
+
});
|
|
159
|
+
expect(revokeRes.status).toBe(200);
|
|
160
|
+
|
|
161
|
+
const listRes = await h.authedPost("/api/query", current.token, {
|
|
162
|
+
type: sessionsScreen.query,
|
|
163
|
+
payload: {},
|
|
164
|
+
});
|
|
165
|
+
const list = (await listRes.json()) as { data: { rows: PanelQueryRow[] } };
|
|
166
|
+
expect(list.data.rows.map((r) => r.id)).toEqual([current.sid]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
type AuthMfaFeatureOptions,
|
|
4
|
+
AuthMfaQueries,
|
|
5
|
+
MFA_DISABLE_SCREEN_ID,
|
|
6
|
+
MFA_ENABLE_SCREEN_ID,
|
|
7
|
+
MFA_REGENERATE_RECOVERY_SCREEN_ID,
|
|
8
|
+
} from "../auth-mfa";
|
|
9
|
+
import { SESSION_MINE_SCREEN_ID } from "../sessions";
|
|
10
|
+
|
|
11
|
+
export const ACCOUNT_SECURITY_SCREEN_ID = "account-security";
|
|
12
|
+
|
|
13
|
+
export const mfaStatusVisibility = { query: AuthMfaQueries.status, field: "enabled" } as const;
|
|
14
|
+
|
|
15
|
+
export const testAuthMfaOptions: AuthMfaFeatureOptions = {
|
|
16
|
+
setupTokenSecret: "test-setup-token-secret-do-not-use-in-prod",
|
|
17
|
+
issuer: "Kumiko Test",
|
|
18
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const accountSecurityFeature = defineFeature("account-security", (r) => {
|
|
22
|
+
r.requires("sessions");
|
|
23
|
+
r.requires("auth-mfa");
|
|
24
|
+
r.screen({
|
|
25
|
+
id: ACCOUNT_SECURITY_SCREEN_ID,
|
|
26
|
+
type: "dashboard",
|
|
27
|
+
panels: [
|
|
28
|
+
{
|
|
29
|
+
kind: "screen",
|
|
30
|
+
id: "mfa-enable",
|
|
31
|
+
screen: `auth-mfa:screen:${MFA_ENABLE_SCREEN_ID}`,
|
|
32
|
+
visibleWhen: { ...mfaStatusVisibility, eq: false },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
kind: "screen",
|
|
36
|
+
id: "mfa-regenerate",
|
|
37
|
+
screen: `auth-mfa:screen:${MFA_REGENERATE_RECOVERY_SCREEN_ID}`,
|
|
38
|
+
visibleWhen: { ...mfaStatusVisibility, eq: true },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
kind: "screen",
|
|
42
|
+
id: "mfa-disable",
|
|
43
|
+
screen: `auth-mfa:screen:${MFA_DISABLE_SCREEN_ID}`,
|
|
44
|
+
visibleWhen: { ...mfaStatusVisibility, eq: true },
|
|
45
|
+
},
|
|
46
|
+
{ kind: "screen", id: "sessions", screen: `sessions:screen:${SESSION_MINE_SCREEN_ID}` },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
r.translations({
|
|
50
|
+
keys: { "screen:account-security.title": { de: "Kontosicherheit", en: "Account security" } },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.259.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Declarative auth-mfa-disable and auth-mfa-regenerate-recovery screens (fw#2844).",
|
|
6
|
+
"detail": "`auth-mfa-disable` (actionForm on auth-mfa:write:disable) and `auth-mfa-regenerate-recovery` (secretMint on auth-mfa:write:regenerate-recovery, one-time reveal of the new codes) join `auth-mfa-enable`, so a settings page can offer the full MFA lifecycle without client components — gate them on `auth-mfa:query:user-mfa:status` via a dashboard screen panel's `visibleWhen`. Exported ids MFA_DISABLE_SCREEN_ID, MFA_REGENERATE_RECOVERY_SCREEN_ID. Additive; handlers unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.165.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -43,3 +43,5 @@ export const MFA_VERIFY_LOCKOUT_MINUTES = 5;
|
|
|
43
43
|
// Dormant custom-screen id — see personal-access-tokens/feature.ts for the
|
|
44
44
|
// same convention. App places it via r.nav in its logged-in settings area.
|
|
45
45
|
export const MFA_ENABLE_SCREEN_ID = "auth-mfa-enable";
|
|
46
|
+
export const MFA_DISABLE_SCREEN_ID = "auth-mfa-disable";
|
|
47
|
+
export const MFA_REGENERATE_RECOVERY_SCREEN_ID = "auth-mfa-regenerate-recovery";
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { AUTH_MFA_FEATURE_I18N } from "./i18n";
|
|
|
13
13
|
import { createMfaCodeVerifier, type MfaCodeVerifier } from "./mfa-code-verifier";
|
|
14
14
|
import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
|
|
15
15
|
import { userMfaEntity } from "./schema/user-mfa";
|
|
16
|
-
import { mfaEnableScreen } from "./screens";
|
|
16
|
+
import { mfaDisableScreen, mfaEnableScreen, mfaRegenerateRecoveryScreen } from "./screens";
|
|
17
17
|
|
|
18
18
|
export type AuthMfaFeatureOptions = {
|
|
19
19
|
// HMAC secret for the stateless enable-flow token (carries the generated
|
|
@@ -130,6 +130,8 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
130
130
|
// logged-in settings area (see personal-access-tokens/feature.ts for the
|
|
131
131
|
// same convention).
|
|
132
132
|
r.screen(mfaEnableScreen);
|
|
133
|
+
r.screen(mfaDisableScreen);
|
|
134
|
+
r.screen(mfaRegenerateRecoveryScreen);
|
|
133
135
|
r.translations({ keys: AUTH_MFA_FEATURE_I18N });
|
|
134
136
|
|
|
135
137
|
// KEK-rotation for totpSecret (entity-field encryption). Manual
|
package/src/auth-mfa/i18n.ts
CHANGED
|
@@ -22,6 +22,16 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
22
22
|
"mfa.enable.confirm.submit": { en: "Enable" },
|
|
23
23
|
"mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
|
|
24
24
|
|
|
25
|
+
"screen:auth-mfa-disable.title": { en: "Disable two-factor authentication" },
|
|
26
|
+
"mfa.disable.submit": { en: "Disable" },
|
|
27
|
+
|
|
28
|
+
"screen:auth-mfa-regenerate-recovery.title": { en: "Generate new recovery codes" },
|
|
29
|
+
"mfa.regenerate.submit": { en: "Generate new codes" },
|
|
30
|
+
"mfa.regenerate.reveal.title": { en: "Your new recovery codes" },
|
|
31
|
+
"mfa.regenerate.reveal.warning": {
|
|
32
|
+
en: "Save these codes now — they are shown only this once, and your previous codes no longer work.",
|
|
33
|
+
},
|
|
34
|
+
|
|
25
35
|
// Field label for the confirm step's pseudo-entity (`__action-form__`, see
|
|
26
36
|
// action-form-shim.ts) — required by the i18n boot-validator
|
|
27
37
|
// (requiredKeysFromScreen); the declarative renderer resolves this through
|
package/src/auth-mfa/index.ts
CHANGED
package/src/auth-mfa/screens.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type ActionFormScreenDefinition,
|
|
3
|
+
i18nKey,
|
|
4
|
+
type SecretMintScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import {
|
|
7
|
+
AuthMfaHandlers,
|
|
8
|
+
MFA_DISABLE_SCREEN_ID,
|
|
9
|
+
MFA_ENABLE_SCREEN_ID,
|
|
10
|
+
MFA_REGENERATE_RECOVERY_SCREEN_ID,
|
|
11
|
+
} from "./constants";
|
|
3
12
|
|
|
4
13
|
// Declarative TOTP-enrollment screen: mint (no input) -> reveal QR/secret/
|
|
5
14
|
// recovery-codes -> confirm with a 6-digit code. `setupToken` is carried
|
|
@@ -50,3 +59,46 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
50
59
|
doneMessage: i18nKey("mfa.enable.confirm.done"),
|
|
51
60
|
},
|
|
52
61
|
};
|
|
62
|
+
|
|
63
|
+
// Either a 6-digit TOTP code or a 9-char recovery code proves possession —
|
|
64
|
+
// same bounds as the disable/regenerate-recovery handler schemas.
|
|
65
|
+
const possessionCodeField = { type: "text", required: true, maxLength: 9 } as const;
|
|
66
|
+
|
|
67
|
+
export const mfaDisableScreen: ActionFormScreenDefinition = {
|
|
68
|
+
id: MFA_DISABLE_SCREEN_ID,
|
|
69
|
+
type: "actionForm",
|
|
70
|
+
handler: AuthMfaHandlers.disable,
|
|
71
|
+
fields: { code: possessionCodeField },
|
|
72
|
+
layout: { sections: [{ fields: ["code"] }] },
|
|
73
|
+
submitLabel: i18nKey("mfa.disable.submit"),
|
|
74
|
+
submitStyle: "danger",
|
|
75
|
+
cancelTarget: false,
|
|
76
|
+
access: { openToAll: true },
|
|
77
|
+
description:
|
|
78
|
+
"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.",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const mfaRegenerateRecoveryScreen: SecretMintScreenDefinition = {
|
|
82
|
+
id: MFA_REGENERATE_RECOVERY_SCREEN_ID,
|
|
83
|
+
type: "secretMint",
|
|
84
|
+
handler: AuthMfaHandlers.regenerateRecovery,
|
|
85
|
+
fields: { code: possessionCodeField },
|
|
86
|
+
layout: { sections: [{ fields: ["code"] }] },
|
|
87
|
+
submitLabel: i18nKey("mfa.regenerate.submit"),
|
|
88
|
+
cancelTarget: false,
|
|
89
|
+
access: { openToAll: true },
|
|
90
|
+
description:
|
|
91
|
+
"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.",
|
|
92
|
+
reveal: {
|
|
93
|
+
title: i18nKey("mfa.regenerate.reveal.title"),
|
|
94
|
+
warning: i18nKey("mfa.regenerate.reveal.warning"),
|
|
95
|
+
fields: [
|
|
96
|
+
{
|
|
97
|
+
field: "recoveryCodes",
|
|
98
|
+
label: i18nKey("mfa.enable.reveal.recoveryCodes"),
|
|
99
|
+
display: "list",
|
|
100
|
+
copyable: true,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -72,7 +72,10 @@ const retainedEntity = createEntity({
|
|
|
72
72
|
// No subject flag on the old fixture either — anonymize alone has no
|
|
73
73
|
// PersonalAnnotations arm, so this stays a spread instead of a direct
|
|
74
74
|
// createTextField(...) override (kumiko-framework#2250).
|
|
75
|
-
label: {
|
|
75
|
+
label: {
|
|
76
|
+
...createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
77
|
+
anonymize: () => "[ANONYMIZED]",
|
|
78
|
+
},
|
|
76
79
|
},
|
|
77
80
|
retention: { keepFor: "30d", strategy: "blockDelete" },
|
|
78
81
|
});
|
|
@@ -82,7 +85,10 @@ const retainedEntity = createEntity({
|
|
|
82
85
|
const anonEntity = createEntity({
|
|
83
86
|
table: "read_c7_anon",
|
|
84
87
|
fields: {
|
|
85
|
-
label: {
|
|
88
|
+
label: {
|
|
89
|
+
...createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
90
|
+
anonymize: () => "[ANONYMIZED]",
|
|
91
|
+
},
|
|
86
92
|
note: createTextField({
|
|
87
93
|
personal: false,
|
|
88
94
|
reason: "is_business_data",
|
|
@@ -114,7 +114,12 @@ export function createNoteMentionEntity() {
|
|
|
114
114
|
description:
|
|
115
115
|
"One row recording that a note names a subject via a structured @-mention, keyed by noteId and the mentioned subject's id.",
|
|
116
116
|
fields: {
|
|
117
|
-
noteId: createTextField({
|
|
117
|
+
noteId: createTextField({
|
|
118
|
+
required: true,
|
|
119
|
+
maxLength: 64,
|
|
120
|
+
personal: false,
|
|
121
|
+
reason: "technical_reference",
|
|
122
|
+
}),
|
|
118
123
|
subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
|
|
119
124
|
},
|
|
120
125
|
});
|
|
@@ -60,7 +60,14 @@ function pastInstant(): Instant {
|
|
|
60
60
|
const CONTACT_TABLE = "notes_mention_forget_test_contacts";
|
|
61
61
|
const contactEntity = createEntity({
|
|
62
62
|
table: CONTACT_TABLE,
|
|
63
|
-
fields: {
|
|
63
|
+
fields: {
|
|
64
|
+
name: createTextField({
|
|
65
|
+
personal: false,
|
|
66
|
+
reason: "test_fixture",
|
|
67
|
+
required: true,
|
|
68
|
+
maxLength: 64,
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
64
71
|
});
|
|
65
72
|
const contactFixtureFeature = defineFeature("notes-mention-forget-test-contact-fixture", (r) => {
|
|
66
73
|
r.entity("contact", contactEntity);
|
|
@@ -300,9 +300,10 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
300
300
|
});
|
|
301
301
|
expect(listRes.status).toBe(200);
|
|
302
302
|
const body = (await listRes.json()) as {
|
|
303
|
-
data: Array<{ id: string; current: boolean }>;
|
|
303
|
+
data: { rows: Array<{ id: string; current: boolean }>; nextCursor: string | null };
|
|
304
304
|
};
|
|
305
|
-
|
|
305
|
+
expect(body.data.nextCursor).toBeNull();
|
|
306
|
+
const ids = body.data.rows.map((r) => r.id);
|
|
306
307
|
// Order: most-recently-created first. c was the last login, so it
|
|
307
308
|
// should lead; a (the first login) trails. Pinning the order stops a
|
|
308
309
|
// silent orderBy removal from slipping through.
|
|
@@ -310,7 +311,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
310
311
|
expect(ids).not.toContain(_b.sid);
|
|
311
312
|
|
|
312
313
|
// The caller's OWN sid is flagged as current
|
|
313
|
-
const currentRow = body.data.find((r) => r.current);
|
|
314
|
+
const currentRow = body.data.rows.find((r) => r.current);
|
|
314
315
|
expect(currentRow?.id).toBe(c.sid);
|
|
315
316
|
});
|
|
316
317
|
|
|
@@ -959,9 +960,9 @@ describe("sessions with active KMS (#820): ip/userAgent are userOwned PII", () =
|
|
|
959
960
|
});
|
|
960
961
|
expect(res.status).toBe(200);
|
|
961
962
|
const body = (await res.json()) as {
|
|
962
|
-
data: Array<{ id: string; ip: string | null; userAgent: string | null }
|
|
963
|
+
data: { rows: Array<{ id: string; ip: string | null; userAgent: string | null }> };
|
|
963
964
|
};
|
|
964
|
-
const manual = body.data.find((s) => s.id === sid);
|
|
965
|
+
const manual = body.data.rows.find((s) => s.id === sid);
|
|
965
966
|
expect(manual?.ip).toBe("203.0.113.7");
|
|
966
967
|
expect(manual?.userAgent).toBe("TestBrowser/1.0");
|
|
967
968
|
} finally {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.259.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "sessions:query:user-session:mine returns the paged envelope { rows, nextCursor }; new self-service my-sessions screen (fw#2844).",
|
|
6
|
+
"detail": "The query backs the new `my-sessions` projectionList (open to every signed-in user): the caller's live sessions with a per-row revoke (hidden on the current session) and \"sign out all other devices\". Exported id SESSION_MINE_SCREEN_ID. Server-side scoping to the caller is unchanged.",
|
|
7
|
+
"migration": "Breaking for direct callers of `sessions:query:user-session:mine`: read `data.rows` instead of treating `data` as the array (`nextCursor` is always null). Account-security custom screens built on this query can be replaced by a dashboard with `kind: \"screen\"` panels on `sessions:screen:my-sessions` and the auth-mfa screens (money-horse#477, publicstatus#435, kumiko-studio#283)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.208.2",
|
|
4
11
|
"type": "improvement",
|
package/src/sessions/feature.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
|
14
14
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
15
15
|
import { SESSIONS_I18N } from "./i18n";
|
|
16
16
|
import { userSessionEntity } from "./schema/user-session";
|
|
17
|
-
import { sessionDetailScreen, sessionListScreen } from "./screens";
|
|
17
|
+
import { sessionDetailScreen, sessionListScreen, sessionMineScreen } from "./screens";
|
|
18
18
|
import {
|
|
19
19
|
createSessionCallbacks,
|
|
20
20
|
type SessionAllOthersRevoker,
|
|
@@ -209,6 +209,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
209
209
|
|
|
210
210
|
r.screen(sessionListScreen);
|
|
211
211
|
r.screen(sessionDetailScreen);
|
|
212
|
+
r.screen(sessionMineScreen);
|
|
212
213
|
r.nav({
|
|
213
214
|
id: "session-list",
|
|
214
215
|
label: "sessions:nav.sessionList",
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import { definePagedQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { decryptStoredPii } from "../../shared";
|
|
5
5
|
import { userSessionTable } from "../schema/user-session";
|
|
6
6
|
|
|
7
7
|
// "My live sessions" — the backing data for a devices/sessions UI. Returns
|
|
8
8
|
// ONLY the current user's own, currently-live sessions, ordered by most-
|
|
9
|
-
// recently-used first. Revoked rows excluded (revokedAt IS NULL).
|
|
10
|
-
|
|
9
|
+
// recently-used first. Revoked rows excluded (revokedAt IS NULL). Paged
|
|
10
|
+
// envelope so the self-service projectionList screen can bind to it; a user's
|
|
11
|
+
// live-session count is small, so there is a single page (nextCursor null).
|
|
12
|
+
export const mineQuery = definePagedQueryHandler({
|
|
11
13
|
name: "user-session:mine",
|
|
12
14
|
schema: z.object({}),
|
|
13
15
|
access: { openToAll: true },
|
|
14
16
|
description:
|
|
15
17
|
"Lists the calling user's own still-live sessions, newest first, each flagged whether it is the one making the request; use it to show a user their signed-in devices.",
|
|
18
|
+
outputSchema: z.object({
|
|
19
|
+
rows: z.array(
|
|
20
|
+
z.object({
|
|
21
|
+
id: z.string(),
|
|
22
|
+
createdAt: z.unknown(),
|
|
23
|
+
expiresAt: z.unknown(),
|
|
24
|
+
ip: z.string().nullable(),
|
|
25
|
+
userAgent: z.string().nullable(),
|
|
26
|
+
current: z.boolean(),
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
nextCursor: z.string().nullable(),
|
|
30
|
+
}),
|
|
16
31
|
handler: async (query, ctx) => {
|
|
17
32
|
const rows = await selectMany<{
|
|
18
33
|
id: string;
|
|
@@ -29,7 +44,7 @@ export const mineQuery = defineQueryHandler({
|
|
|
29
44
|
},
|
|
30
45
|
);
|
|
31
46
|
const currentSid = query.user.sid;
|
|
32
|
-
|
|
47
|
+
const decryptedRows = await Promise.all(
|
|
33
48
|
rows.map(async (r) => ({
|
|
34
49
|
id: r.id,
|
|
35
50
|
createdAt: r.createdAt,
|
|
@@ -41,5 +56,6 @@ export const mineQuery = defineQueryHandler({
|
|
|
41
56
|
current: currentSid === r.id,
|
|
42
57
|
})),
|
|
43
58
|
);
|
|
59
|
+
return { rows: decryptedRows, nextCursor: null };
|
|
44
60
|
},
|
|
45
61
|
});
|
package/src/sessions/i18n.ts
CHANGED
|
@@ -20,4 +20,14 @@ export const SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
20
20
|
"sessions.detail.field.revokedAt": { en: "Revoked" },
|
|
21
21
|
"sessions.detail.field.ip": { en: "IP address" },
|
|
22
22
|
"sessions.detail.field.userAgent": { en: "User agent" },
|
|
23
|
+
"screen:my-sessions.title": { en: "Your sessions" },
|
|
24
|
+
"sessions.mine.col.ip": { en: "IP address" },
|
|
25
|
+
"sessions.mine.col.userAgent": { en: "Device" },
|
|
26
|
+
"sessions.mine.col.current": { en: "This device" },
|
|
27
|
+
"sessions.mine.revoke": { en: "Sign out" },
|
|
28
|
+
"sessions.mine.revoke.confirm": { en: "Sign this device out?" },
|
|
29
|
+
"sessions.mine.revokeAllOthers": { en: "Sign out all other devices" },
|
|
30
|
+
"sessions.mine.revokeAllOthers.confirm": {
|
|
31
|
+
en: "Sign out every device except this one?",
|
|
32
|
+
},
|
|
23
33
|
};
|
package/src/sessions/index.ts
CHANGED
package/src/sessions/screens.ts
CHANGED
|
@@ -4,7 +4,13 @@ import {
|
|
|
4
4
|
type ProjectionDetailScreenDefinition,
|
|
5
5
|
type ProjectionListScreenDefinition,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SESSION_DETAIL_SCREEN_ID,
|
|
9
|
+
SESSION_LIST_SCREEN_ID,
|
|
10
|
+
SESSION_MINE_SCREEN_ID,
|
|
11
|
+
SessionHandlers,
|
|
12
|
+
SessionQueries,
|
|
13
|
+
} from "./constants";
|
|
8
14
|
|
|
9
15
|
const listAccess = { roles: access.admin };
|
|
10
16
|
|
|
@@ -88,3 +94,51 @@ export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
|
|
|
88
94
|
},
|
|
89
95
|
access: listAccess,
|
|
90
96
|
};
|
|
97
|
+
|
|
98
|
+
// Revoke is hidden on the current session — it would sign the user out mid-click; logout covers it.
|
|
99
|
+
export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
100
|
+
id: SESSION_MINE_SCREEN_ID,
|
|
101
|
+
type: "projectionList",
|
|
102
|
+
query: SessionQueries.mine,
|
|
103
|
+
columns: [
|
|
104
|
+
{
|
|
105
|
+
field: "createdAt",
|
|
106
|
+
label: i18nKey("sessions.list.col.createdAt"),
|
|
107
|
+
renderer: { format: "timestamp" },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
field: "expiresAt",
|
|
111
|
+
label: i18nKey("sessions.list.col.expiresAt"),
|
|
112
|
+
renderer: { format: "timestamp" },
|
|
113
|
+
},
|
|
114
|
+
{ field: "ip", label: i18nKey("sessions.mine.col.ip") },
|
|
115
|
+
{ field: "userAgent", label: i18nKey("sessions.mine.col.userAgent") },
|
|
116
|
+
{
|
|
117
|
+
field: "current",
|
|
118
|
+
label: i18nKey("sessions.mine.col.current"),
|
|
119
|
+
renderer: { format: "boolean" },
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
rowActions: [
|
|
123
|
+
{
|
|
124
|
+
kind: "writeHandler",
|
|
125
|
+
id: "revoke",
|
|
126
|
+
label: i18nKey("sessions.mine.revoke"),
|
|
127
|
+
handler: SessionHandlers.revoke,
|
|
128
|
+
style: "danger",
|
|
129
|
+
confirm: i18nKey("sessions.mine.revoke.confirm"),
|
|
130
|
+
visible: { field: "current", eq: false },
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
toolbarActions: [
|
|
134
|
+
{
|
|
135
|
+
kind: "writeHandler",
|
|
136
|
+
id: "revoke-all-others",
|
|
137
|
+
label: i18nKey("sessions.mine.revokeAllOthers"),
|
|
138
|
+
handler: SessionHandlers.revokeAllOthers,
|
|
139
|
+
style: "danger",
|
|
140
|
+
confirm: i18nKey("sessions.mine.revokeAllOthers.confirm"),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
access: { openToAll: true },
|
|
144
|
+
};
|