@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.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +22 -7
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- 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 {
|
|
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
|
-
}):
|
|
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([
|
|
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([
|
|
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
|
-
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
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
|
-
|
|
142
|
-
expect(
|
|
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
|
|
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
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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(
|
|
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("
|
|
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,
|
|
495
|
-
// session, Bob's session.
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
actor,
|
|
40
53
|
);
|
|
41
54
|
await seedTenantMembership(stack.db, {
|
|
42
55
|
userId: created.id,
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
);
|
package/src/sessions/testing.ts
CHANGED
|
@@ -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
|
+
}
|