@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.258.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.258.0",
133
- "@cosmicdrift/kumiko-framework": "0.258.0",
134
- "@cosmicdrift/kumiko-headless": "0.258.0",
135
- "@cosmicdrift/kumiko-renderer": "0.258.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.258.0",
137
- "@cosmicdrift/kumiko-types": "0.258.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
- "@types/qrcode": "^1.5.5",
166
- "@cosmicdrift/kumiko-locale-de": "0.258.0",
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";
@@ -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
@@ -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
@@ -4,7 +4,9 @@ export {
4
4
  AUTH_MFA_FEATURE,
5
5
  AuthMfaHandlers,
6
6
  AuthMfaQueries,
7
+ MFA_DISABLE_SCREEN_ID,
7
8
  MFA_ENABLE_SCREEN_ID,
9
+ MFA_REGENERATE_RECOVERY_SCREEN_ID,
8
10
  } from "./constants";
9
11
  export type {
10
12
  AuthMfaFeatureOptions,
@@ -1,5 +1,14 @@
1
- import { i18nKey, type SecretMintScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { AuthMfaHandlers, MFA_ENABLE_SCREEN_ID } from "./constants";
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: { ...createTextField({ required: true }), anonymize: () => "[ANONYMIZED]" },
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: { ...createTextField({ required: true }), anonymize: () => "[ANONYMIZED]" },
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({ required: true, maxLength: 64 }),
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: { name: createTextField({ required: true, maxLength: 64 }) },
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
- const ids = body.data.map((r) => r.id);
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",
@@ -54,3 +54,4 @@ export const LAST_SEEN_REFRESH_MS = 3_600_000;
54
54
 
55
55
  export const SESSION_LIST_SCREEN_ID = "session-list" as const;
56
56
  export const SESSION_DETAIL_SCREEN_ID = "session-detail" as const;
57
+ export const SESSION_MINE_SCREEN_ID = "my-sessions" as const;
@@ -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 { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
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
- export const mineQuery = defineQueryHandler({
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
- return Promise.all(
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
  });
@@ -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
  };
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  DEFAULT_SESSION_CACHE_TTL_MS,
3
3
  DEFAULT_SESSION_EXPIRY_MS,
4
+ SESSION_MINE_SCREEN_ID,
4
5
  SESSIONS_FEATURE,
5
6
  SessionErrors,
6
7
  SessionHandlers,
@@ -4,7 +4,13 @@ import {
4
4
  type ProjectionDetailScreenDefinition,
5
5
  type ProjectionListScreenDefinition,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
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
+ };