@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1

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.
Files changed (149) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -29,7 +29,7 @@ export const listPatQuery = defineQueryHandler({
29
29
  return Promise.all(
30
30
  rows.map(async (r) => ({
31
31
  id: r.id,
32
- name: await decryptStoredPii(r.name, "pat:list"),
32
+ name: await decryptStoredPii(r.name, "name", "pat:list"),
33
33
  prefix: r.prefix,
34
34
  scopes: parseScopeNames(r.scopes),
35
35
  createdAt: r.createdAt,
@@ -1,6 +1,5 @@
1
1
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { PAT_DEFAULT_RATE_LIMIT, type PatRateLimit } from "./constants";
3
- import type { PatScopeConfig } from "./scopes";
4
3
 
5
4
  export type { PatRateLimit } from "./constants";
6
5
  export { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PatHandlers, PatQueries } from "./constants";
@@ -12,19 +11,6 @@ export { apiTokenEntity, apiTokenTable } from "./schema/api-token";
12
11
  export type { PatScopeConfig, PatScopeDef } from "./scopes";
13
12
  export { expandScopes } from "./scopes";
14
13
 
15
- // Reads the scope catalog off a mounted PAT feature's exports (set from the
16
- // setup-callback return). run-prod-app uses this to build the resolver from the
17
- // same config the handlers were given. Returns {} for a non-PAT / malformed
18
- // definition — the caller has already matched on feature.name.
19
- export function patScopesFromFeature(feature: FeatureDefinition): PatScopeConfig {
20
- const exports = feature.exports;
21
- if (exports && typeof exports === "object" && "scopes" in exports) {
22
- const { scopes } = exports as { scopes: unknown };
23
- if (scopes && typeof scopes === "object") return scopes as PatScopeConfig;
24
- }
25
- return {};
26
- }
27
-
28
14
  // Reads the per-token rate-limit config off a mounted PAT feature's exports.
29
15
  // Falls back to the default when absent. run-prod-app uses this to build the
30
16
  // limiter from the same declaration the feature was given.
@@ -1,4 +1,4 @@
1
- import type { PatResolver } from "@cosmicdrift/kumiko-framework/api";
1
+ import type { TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
@@ -16,7 +16,7 @@ import { expandScopes, type PatScopeConfig } from "./scopes";
16
16
  export function createPatResolver(opts: {
17
17
  readonly db: DbConnection;
18
18
  readonly scopes: PatScopeConfig;
19
- }): PatResolver {
19
+ }): TokenVerifier {
20
20
  const { db, scopes } = opts;
21
21
  return async (rawToken: string): Promise<SessionUser | null> => {
22
22
  const row = await fetchOne<{
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
9
  import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
10
11
  import {
11
12
  createEnvMasterKeyProvider,
12
13
  decodeStoredEnvelope,
@@ -235,5 +236,35 @@ describe("rotate-job circuit-breaker", () => {
235
236
  const plaintext = await decryptValue(decodeStoredEnvelope(row.envelope), verifier);
236
237
  expect(plaintext).toBe(`secret-${expectedIndex}`);
237
238
  }
239
+
240
+ // Regression guard (kumiko-framework#1189): rotate.job.ts's
241
+ // executor.update() call carries no options, so it deliberately
242
+ // never sets skipUnchanged — every rotation writes a real .updated
243
+ // event, not a raw UPDATE. A full projection rebuild replays those
244
+ // events; if a future refactor added skipUnchanged (or swapped the
245
+ // event write for a raw column update) the rebuild would resurrect
246
+ // the pre-rotation V1 envelopes instead of landing on V2, exactly
247
+ // the #464 failure class config/auth-mfa already guard against.
248
+ //
249
+ // Vacuous-rebuild guard: confirm the projection name actually exists
250
+ // and the rebuild really replayed events, so a wrong projection name
251
+ // can't silently no-op the assertion below into a false pass.
252
+ const projectionName = "secrets:projection:tenant-secret-entity";
253
+ expect(stack.registry.getAllProjections().has(projectionName)).toBe(true);
254
+ const rebuildResult = await rebuildProjection(projectionName, {
255
+ db: stack.db,
256
+ registry: stack.registry,
257
+ });
258
+ expect(rebuildResult.eventsProcessed).toBeGreaterThan(0);
259
+
260
+ const rowsAfterRebuild = await selectMany<{ key: string; kekVersion: number }>(
261
+ stack.db,
262
+ tenantSecretsTable,
263
+ { tenantId: admin.tenantId },
264
+ );
265
+ expect(rowsAfterRebuild).toHaveLength(20);
266
+ for (const row of rowsAfterRebuild) {
267
+ expect(row.kekVersion).toBe(2);
268
+ }
238
269
  });
239
270
  });
@@ -5,6 +5,7 @@
5
5
  // expired/revoked rows go, live rows stay, batching + signal work.
6
6
 
7
7
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
8
9
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
10
  import { sql } from "@cosmicdrift/kumiko-framework/db";
10
11
  import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
@@ -40,7 +41,7 @@ let stack: TestStack;
40
41
 
41
42
  beforeAll(async () => {
42
43
  stack = await setupTestStack({
43
- features: [createSessionsFeature(), createUserFeature()],
44
+ features: [authFoundationFeature, createSessionsFeature(), createUserFeature()],
44
45
  });
45
46
  await unsafeCreateEntityTable(stack.db, userSessionEntity);
46
47
  await unsafeCreateEntityTable(stack.db, userEntity);
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
4
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
@@ -66,6 +67,7 @@ beforeAll(async () => {
66
67
  createUserFeature(),
67
68
  createTenantFeature(),
68
69
  createAuthEmailPasswordFeature(),
70
+ authFoundationFeature,
69
71
  createSessionsFeature({
70
72
  autoRevokeOnPasswordChange: massRevokeSpy,
71
73
  }),
@@ -78,7 +80,7 @@ beforeAll(async () => {
78
80
  },
79
81
  });
80
82
  callbacks.set(createSessionCallbacks({ db: stack.db }));
81
- h = makeSessionHelpers(stack, TENANT);
83
+ h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
82
84
 
83
85
  await unsafeCreateEntityTable(stack.db, userEntity);
84
86
  await unsafeCreateEntityTable(stack.db, tenantEntity);
@@ -99,7 +101,7 @@ beforeEach(async () => {
99
101
 
100
102
  describe("password change mass-revokes every live session", () => {
101
103
  test("changing the password revokes ALL sessions including the caller's", async () => {
102
- await h.seedUser("rotate@example.com", "first-password");
104
+ const { userId } = await h.seedUser("rotate@example.com", "first-password");
103
105
 
104
106
  const a = await h.login("rotate@example.com", "first-password");
105
107
  const b = await h.login("rotate@example.com", "first-password");
@@ -147,7 +149,7 @@ describe("password change mass-revokes every live session", () => {
147
149
 
148
150
  // DB state confirms: zero live rows for this user
149
151
  const liveRows = await selectMany(stack.db, userSessionTable);
150
- const stillLive = liveRows.filter((r) => r["revokedAt"] === null);
152
+ const stillLive = liveRows.filter((r) => r["userId"] === userId && r["revokedAt"] === null);
151
153
  expect(stillLive).toHaveLength(0);
152
154
 
153
155
  // And logging in again with the NEW password works
@@ -1,4 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
2
3
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
4
  import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
5
  import { createRegistry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
@@ -72,14 +73,22 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
72
73
 
73
74
  describe("sessions / store_user_sessions survives projection rebuild", () => {
74
75
  test("is NOT registered as a rebuildable implicit projection", () => {
75
- const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
76
+ const registry = createRegistry([
77
+ authFoundationFeature,
78
+ createSessionsFeature(),
79
+ createUserFeature(),
80
+ ]);
76
81
  expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
77
82
  });
78
83
 
79
84
  test("direct-written rows (incl. revoked state) survive a rebuild", async () => {
80
85
  await insertRevokedSession(createTenantDb(testDb.db, TENANT));
81
86
 
82
- const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
87
+ const registry = createRegistry([
88
+ authFoundationFeature,
89
+ createSessionsFeature(),
90
+ createUserFeature(),
91
+ ]);
83
92
  // Pre-fix: the implicit projection exists → rebuild swaps an empty shadow
84
93
  // → rows wiped. Post-fix: absent → no rebuild → rows untouched. Either way
85
94
  // a regression (re-adding r.entity) makes this fail.
@@ -0,0 +1,21 @@
1
+ // sessions self-registers as auth-foundation's sessionStore provider (#1371)
2
+ // instead of only being wired by hand into buildServer({ auth: { ... } }).
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { EXT_SESSION_STORE } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
6
+ import { createSessionsFeature } from "../feature";
7
+
8
+ describe("createSessionsFeature — sessionStore registration (#1371)", () => {
9
+ test('registers via r.useExtension(EXT_SESSION_STORE, "default", ...)', () => {
10
+ const feature = createSessionsFeature();
11
+ const usage = feature.extensionUsages.find((u) => u.extensionName === EXT_SESSION_STORE);
12
+ expect(usage).toBeDefined();
13
+ expect(usage?.entityName).toBe("default");
14
+ expect(typeof (usage?.options as { build?: unknown } | undefined)?.build).toBe("function");
15
+ });
16
+
17
+ test("requires auth-foundation (owner of EXT_SESSION_STORE)", () => {
18
+ const feature = createSessionsFeature();
19
+ expect(feature.requires).toContain("auth-foundation");
20
+ });
21
+ });
@@ -1,17 +1,26 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { EXT_SESSION_STORE } from "../../auth-foundation";
4
5
  import { createConfigFeature } from "../../config/feature";
5
6
  import { createTenantFeature } from "../../tenant";
6
7
  import { createUserFeature } from "../../user/feature";
7
8
  import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID } from "../constants";
8
9
  import { createSessionsFeature } from "../feature";
9
10
 
11
+ // Stub instead of the real auth-foundation — this test is about screen/query
12
+ // wiring, not auth; the real feature also mandates a tokenVerifier provider
13
+ // which is out of scope here.
14
+ const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
15
+ r.extendsRegistrar(EXT_SESSION_STORE, {});
16
+ });
17
+
10
18
  describe("sessions screens + query access alignment (kumiko-framework#255)", () => {
11
19
  const features = [
12
20
  createConfigFeature(),
13
21
  createUserFeature(),
14
22
  createTenantFeature(),
23
+ sessionStoreOwnerStub,
15
24
  createSessionsFeature(),
16
25
  ];
17
26
 
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
4
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
5
  import {
5
6
  configureBlindIndexKey,
@@ -13,6 +14,7 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
14
  import {
14
15
  setupTestStack,
15
16
  type TestStack,
17
+ TestUsers,
16
18
  testTenantId,
17
19
  unsafeCreateEntityTable,
18
20
  unsafePushTables,
@@ -65,6 +67,7 @@ beforeAll(async () => {
65
67
  createUserFeature(),
66
68
  createTenantFeature(),
67
69
  createAuthEmailPasswordFeature(),
70
+ authFoundationFeature,
68
71
  createSessionsFeature(),
69
72
  ],
70
73
  extraContext: { configResolver: resolver, configEncryption: encryption },
@@ -75,7 +78,7 @@ beforeAll(async () => {
75
78
  },
76
79
  });
77
80
  callbacks.set(createSessionCallbacks({ db: stack.db }));
78
- h = makeSessionHelpers(stack, TENANT);
81
+ h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
79
82
 
80
83
  await unsafeCreateEntityTable(stack.db, userEntity);
81
84
  await unsafeCreateEntityTable(stack.db, tenantEntity);
@@ -97,9 +100,10 @@ describe("sessions feature — login → check → revoke → rejected", () => {
97
100
  const { sid } = await h.login("persist@example.com", "pw-long-enough");
98
101
 
99
102
  const rows = await selectMany(stack.db, userSessionTable);
100
- expect(rows).toHaveLength(1);
101
- expect(rows[0]?.["id"]).toBe(sid);
102
- expect(rows[0]?.["revokedAt"]).toBeNull();
103
+ const userRows = rows.filter((r) => r["userId"] !== TestUsers.systemAdmin.id);
104
+ expect(userRows).toHaveLength(1);
105
+ expect(userRows[0]?.["id"]).toBe(sid);
106
+ expect(userRows[0]?.["revokedAt"]).toBeNull();
103
107
  });
104
108
 
105
109
  test("authenticated request passes while session is live, 401s once revoked", async () => {
@@ -138,8 +142,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
138
142
  expect(logoutRes.status).toBe(200);
139
143
 
140
144
  const rows = await selectMany(stack.db, userSessionTable);
141
- expect(rows[0]?.["id"]).toBe(sid);
142
- expect(rows[0]?.["revokedAt"]).not.toBeNull();
145
+ const row = rows.find((r) => r["id"] === sid);
146
+ expect(row?.["id"]).toBe(sid);
147
+ expect(row?.["revokedAt"]).not.toBeNull();
143
148
 
144
149
  const next = await h.authedPost("/api/query", token, {
145
150
  type: "user:query:user:me",
@@ -294,12 +299,13 @@ describe("sessions feature — login → check → revoke → rejected", () => {
294
299
  expect(body.error?.details?.reason).toBe("ownership_denied");
295
300
  });
296
301
 
297
- test("revoke-all-others on a sidless JWT refuses loudly (session_required)", async () => {
302
+ test("revoke-all-others on a sidless JWT is rejected by the middleware (no_sid)", async () => {
298
303
  const { userId } = await h.seedUser("sidless@example.com", "pw-long-enough");
299
304
 
300
- // Hand-sign a JWT without jti — simulates a stateless-JWT deployment
301
- // or a rolling-deploy gap. "sign out everywhere else" is ill-defined
302
- // without knowing which session is "current", so refuse.
305
+ // Hand-sign a JWT without jti. Once a sessionChecker is wired there is
306
+ // no relaxed mode (see session-strict-mode.integration.test.ts) — the
307
+ // middleware rejects it with no_sid before the handler's own
308
+ // session_required guard is ever reached.
303
309
  const tokenNoSid = await stack.jwt.sign({ id: userId, tenantId: TENANT, roles: ["User"] });
304
310
 
305
311
  const res = await stack.http.raw(
@@ -308,9 +314,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
308
314
  { type: SessionHandlers.revokeAllOthers, payload: {} },
309
315
  { Authorization: `Bearer ${tokenNoSid}` },
310
316
  );
311
- expect(res.status).toBe(422);
317
+ expect(res.status).toBe(401);
312
318
  const body = (await res.json()) as { error?: { details?: { reason?: string } } };
313
- expect(body.error?.details?.reason).toBe("session_required");
319
+ expect(body.error?.details?.reason).toBe("no_sid");
314
320
  });
315
321
 
316
322
  test("N concurrent revoke+logout requests on the same sid converge safely", async () => {
@@ -491,10 +497,13 @@ describe("sessions feature — login → check → revoke → rejected", () => {
491
497
  revokedAt: string | null;
492
498
  }>;
493
499
  };
494
- // Three rows total: Alice's pre-promotion session, Alice's post-promotion
495
- // session, Bob's session. Two distinct users.
496
- expect(body.data).toHaveLength(3);
497
- const userIds = new Set(body.data.map((r) => r.userId));
500
+ // Three rows total for the two test users: Alice's pre-promotion session,
501
+ // Alice's post-promotion session, Bob's session. seedUser's bootstrap
502
+ // actor (systemAdmin) also holds live sessions in this tenant — exclude
503
+ // them, they aren't part of what this test pins.
504
+ const nonSystemRows = body.data.filter((r) => r.userId !== TestUsers.systemAdmin.id);
505
+ expect(nonSystemRows).toHaveLength(3);
506
+ const userIds = new Set(nonSystemRows.map((r) => r.userId));
498
507
  expect(userIds.size).toBe(2);
499
508
 
500
509
  // Order: most-recently-created first. aliceAsAdmin's session was the
@@ -552,7 +561,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
552
561
  // different tenant must not be able to read another tenant's session rows.
553
562
  test("session:detail and session:list never leak sessions across tenants", async () => {
554
563
  const TENANT_2 = testTenantId(2);
555
- const h2 = makeSessionHelpers(stack, TENANT_2);
564
+ const h2 = makeSessionHelpers(stack, TENANT_2, (user, meta) =>
565
+ callbacks.get().sessionCreator(user, meta),
566
+ );
556
567
 
557
568
  const { userId: carolId } = await h.seedUser("carol4@example.com", "pw-long-enough");
558
569
  await updateRows(
@@ -9,12 +9,14 @@
9
9
  // const res = await h.authedPost("/api/query", token, { type, payload });
10
10
 
11
11
  import { expect } from "bun:test";
12
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
12
13
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
14
  import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
15
  import * as jose from "jose";
15
16
  import { hashPassword } from "../../shared";
16
17
  import { seedTenantMembership } from "../../tenant/seeding";
17
18
  import { UserHandlers } from "../../user";
19
+ import { withMintedSession } from "../testing";
18
20
 
19
21
  export type LoginResult = {
20
22
  readonly token: string;
@@ -25,7 +27,15 @@ export type LoginResult = {
25
27
  // `ReturnType<typeof makeSessionHelpers>` — no separate type export to
26
28
  // keep in sync with the implementation. Params are typed inline on each
27
29
  // method so the inference is sharp.
28
- export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
30
+ // `sessionCreator` is only needed when the suite wires a real sessionChecker
31
+ // (auth-middleware then rejects the sidless systemAdmin bootstrap actor
32
+ // used by seedUser). Suites without a sessionChecker (e.g. PAT tests) can
33
+ // omit it.
34
+ export function makeSessionHelpers(
35
+ stack: TestStack,
36
+ tenantId: TenantId,
37
+ sessionCreator?: SessionCreator,
38
+ ) {
29
39
  return {
30
40
  async seedUser(
31
41
  email: string,
@@ -33,10 +43,13 @@ export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
33
43
  opts?: { roles?: readonly string[] },
34
44
  ): Promise<{ userId: string }> {
35
45
  const hash = await hashPassword(password);
46
+ const actor = sessionCreator
47
+ ? await withMintedSession(sessionCreator, TestUsers.systemAdmin)
48
+ : TestUsers.systemAdmin;
36
49
  const created = await stack.http.writeOk<{ id: string }>(
37
50
  UserHandlers.create,
38
51
  { email, passwordHash: hash, displayName: email.split("@")[0] ?? "u" },
39
- TestUsers.systemAdmin,
52
+ actor,
40
53
  );
41
54
  await seedTenantMembership(stack.db, {
42
55
  userId: created.id,
@@ -1,3 +1,8 @@
1
+ import {
2
+ EXT_SESSION_STORE,
3
+ type SessionStore,
4
+ type SessionStoreProvider,
5
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
1
6
  import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
7
  import {
3
8
  access,
@@ -14,7 +19,11 @@ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
14
19
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
15
20
  import { SESSIONS_I18N } from "./i18n";
16
21
  import { userSessionEntity } from "./schema/user-session";
17
- import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
22
+ import {
23
+ createSessionCallbacks,
24
+ type SessionAllOthersRevoker,
25
+ type SessionMassRevoker,
26
+ } from "./session-callbacks";
18
27
 
19
28
  export type SessionsFeatureOptions = {
20
29
  // A successful update on the `user` entity that changes the `passwordHash`
@@ -76,7 +85,7 @@ export function bindAutoRevokeFromFeature(
76
85
  export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
77
86
  return defineFeature("sessions", (r) => {
78
87
  r.describe(
79
- "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
88
+ "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
80
89
  );
81
90
  r.uiHints({
82
91
  displayLabel: "Sessions · Server-side Logout",
@@ -86,7 +95,9 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
86
95
  // sessionChecker reads read_users on every authenticated request (status
87
96
  // gate for locked accounts) — make that a boot-time dependency so a
88
97
  // sessions-without-user wiring fails validateBoot instead of 500ing live.
89
- r.requires("user");
98
+ // auth-foundation owns EXT_SESSION_STORE, which the useExtension below
99
+ // registers against.
100
+ r.requires("user", "auth-foundation");
90
101
  // store_user_sessions is a hot-path direct-write store: sessionCreator
91
102
  // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
92
103
  // events (the row columns ARE the audit trail). Registering it as
@@ -102,6 +113,23 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
102
113
  piiEncryptedOnWrite: true,
103
114
  });
104
115
 
116
+ // Self-registers as auth-foundation's sessionStore provider (#1371) —
117
+ // wraps the same createSessionCallbacks() used by the manual
118
+ // buildServer({ auth: { ... } }) wiring above; a future issue (#1372)
119
+ // removes that manual wiring once the middleware resolves generically
120
+ // via resolveSessionStore().
121
+ r.useExtension(EXT_SESSION_STORE, "default", {
122
+ build: (deps): SessionStore => {
123
+ const callbacks = createSessionCallbacks({ db: deps.db });
124
+ return {
125
+ creator: callbacks.sessionCreator,
126
+ revoker: callbacks.sessionRevoker,
127
+ checker: callbacks.sessionChecker,
128
+ massRevoker: callbacks.sessionMassRevoker,
129
+ };
130
+ },
131
+ } satisfies SessionStoreProvider);
132
+
105
133
  const handlers = {
106
134
  revoke: r.writeHandler(revokeWrite),
107
135
  revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
@@ -27,9 +27,9 @@ export const detailQuery = defineQueryHandler({
27
27
  createdAt: row.createdAt,
28
28
  expiresAt: row.expiresAt,
29
29
  revokedAt: row.revokedAt,
30
- ip: row.ip ? await decryptStoredPii(row.ip, "sessions:detail") : row.ip,
30
+ ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
31
31
  userAgent: row.userAgent
32
- ? await decryptStoredPii(row.userAgent, "sessions:detail")
32
+ ? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
33
33
  : row.userAgent,
34
34
  };
35
35
  },
@@ -30,8 +30,10 @@ export const listQuery = defineQueryHandler({
30
30
  createdAt: r.createdAt,
31
31
  expiresAt: r.expiresAt,
32
32
  revokedAt: r.revokedAt,
33
- ip: r.ip ? await decryptStoredPii(r.ip, "sessions:list") : r.ip,
34
- userAgent: r.userAgent ? await decryptStoredPii(r.userAgent, "sessions:list") : r.userAgent,
33
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
34
+ userAgent: r.userAgent
35
+ ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
36
+ : r.userAgent,
35
37
  })),
36
38
  );
37
39
  },
@@ -32,8 +32,10 @@ export const mineQuery = defineQueryHandler({
32
32
  id: r.id,
33
33
  createdAt: r.createdAt,
34
34
  expiresAt: r.expiresAt,
35
- ip: r.ip ? await decryptStoredPii(r.ip, "sessions:mine") : r.ip,
36
- userAgent: r.userAgent ? await decryptStoredPii(r.userAgent, "sessions:mine") : r.userAgent,
35
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
36
+ userAgent: r.userAgent
37
+ ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:mine")
38
+ : r.userAgent,
37
39
  current: currentSid === r.id,
38
40
  })),
39
41
  );
@@ -17,7 +17,8 @@
17
17
  // sessions feature owns. framework/testing only provides the generic
18
18
  // `createLateBoundHolder<T>` — shape-independent.
19
19
 
20
- import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
20
+ import type { AuthRoutesConfig, SessionCreator } from "@cosmicdrift/kumiko-framework/api";
21
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
21
22
  import type { LateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
22
23
  import type { SessionCallbacks, SessionMassRevoker } from "./session-callbacks";
23
24
 
@@ -40,3 +41,15 @@ export function sessionCallbacksFromLateBound(
40
41
  asMassRevoker: () => (userId) => holder.get().sessionMassRevoker(userId),
41
42
  };
42
43
  }
44
+
45
+ // Bootstrap actors (system-admin seed writes) need a real sid once a
46
+ // sessionChecker is wired — auth-middleware rejects sidless JWTs as
47
+ // forged/pre-session-tracking. Mints a live session row and returns the
48
+ // actor with `sid` attached so `stack.http.writeOk(..., actor)` passes.
49
+ export async function withMintedSession(
50
+ sessionCreator: SessionCreator,
51
+ user: SessionUser,
52
+ ): Promise<SessionUser> {
53
+ const sid = await sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
54
+ return { ...user, sid };
55
+ }