@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import {
|
|
5
|
+
createEntityTable,
|
|
6
|
+
pushTables,
|
|
7
|
+
setupTestStack,
|
|
8
|
+
type TestStack,
|
|
9
|
+
testTenantId,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
|
+
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
+
import { and, eq } from "drizzle-orm";
|
|
13
|
+
import { Temporal } from "temporal-polyfill";
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
15
|
+
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
16
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
|
+
import { createConfigFeature } from "../../config";
|
|
18
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
19
|
+
import { configValuesTable } from "../../config/table";
|
|
20
|
+
import { createTenantFeature } from "../../tenant";
|
|
21
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
22
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
23
|
+
import { createUserFeature } from "../../user/feature";
|
|
24
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
25
|
+
import { SessionHandlers, SessionQueries } from "../constants";
|
|
26
|
+
import { createSessionsFeature } from "../feature";
|
|
27
|
+
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
28
|
+
import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
|
|
29
|
+
import { sessionCallbacksFromLateBound } from "../testing";
|
|
30
|
+
import { makeSessionHelpers } from "./test-helpers";
|
|
31
|
+
|
|
32
|
+
// End-to-end test of the sessions feature. Full loop: login persists a
|
|
33
|
+
// session → JWT carries jti → middleware checks it on every subsequent
|
|
34
|
+
// request → revoke flips the DB row → the previously-valid JWT is rejected
|
|
35
|
+
// on the next call. No mocks — real Drizzle + HTTP.
|
|
36
|
+
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
let h: ReturnType<typeof makeSessionHelpers>;
|
|
39
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
40
|
+
|
|
41
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
42
|
+
const TENANT: TenantId = testTenantId(1);
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
46
|
+
const resolver = createConfigResolver({ encryption });
|
|
47
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
48
|
+
|
|
49
|
+
stack = await setupTestStack({
|
|
50
|
+
features: [
|
|
51
|
+
createConfigFeature(),
|
|
52
|
+
createUserFeature(),
|
|
53
|
+
createTenantFeature(),
|
|
54
|
+
createAuthEmailPasswordFeature(),
|
|
55
|
+
createSessionsFeature(),
|
|
56
|
+
],
|
|
57
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
58
|
+
authConfig: {
|
|
59
|
+
...bound.asAuthConfig(),
|
|
60
|
+
membershipQuery: "tenant:query:memberships",
|
|
61
|
+
loginHandler: AuthHandlers.login,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
65
|
+
h = makeSessionHelpers(stack, TENANT);
|
|
66
|
+
|
|
67
|
+
await createEntityTable(stack.db, userEntity);
|
|
68
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
69
|
+
await createEntityTable(stack.db, userSessionEntity);
|
|
70
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await stack.cleanup();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
await stack.db.delete(userTable);
|
|
79
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
80
|
+
await stack.db.delete(userSessionTable);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("sessions feature — login → check → revoke → rejected", () => {
|
|
84
|
+
test("login persists a userSession row with PK equal to the JWT jti", async () => {
|
|
85
|
+
await h.seedUser("persist@example.com", "pw-long-enough");
|
|
86
|
+
const { sid } = await h.login("persist@example.com", "pw-long-enough");
|
|
87
|
+
|
|
88
|
+
const rows = await stack.db.select().from(userSessionTable);
|
|
89
|
+
expect(rows).toHaveLength(1);
|
|
90
|
+
expect(rows[0]?.["id"]).toBe(sid);
|
|
91
|
+
expect(rows[0]?.["revokedAt"]).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("authenticated request passes while session is live, 401s once revoked", async () => {
|
|
95
|
+
await h.seedUser("round@example.com", "pw-long-enough");
|
|
96
|
+
const { token, sid } = await h.login("round@example.com", "pw-long-enough");
|
|
97
|
+
|
|
98
|
+
// Before revoke: the me-query comes back with data
|
|
99
|
+
const before = await h.authedPost("/api/query", token, {
|
|
100
|
+
type: "user:query:user:me",
|
|
101
|
+
payload: {},
|
|
102
|
+
});
|
|
103
|
+
expect(before.status).toBe(200);
|
|
104
|
+
|
|
105
|
+
// Revoke this session via the feature handler (user revokes their own)
|
|
106
|
+
const revokeRes = await h.authedPost("/api/write", token, {
|
|
107
|
+
type: SessionHandlers.revoke,
|
|
108
|
+
payload: { id: sid },
|
|
109
|
+
});
|
|
110
|
+
expect(revokeRes.status).toBe(200);
|
|
111
|
+
|
|
112
|
+
// After revoke: the SAME JWT is rejected by middleware
|
|
113
|
+
const after = await h.authedPost("/api/query", token, {
|
|
114
|
+
type: "user:query:user:me",
|
|
115
|
+
payload: {},
|
|
116
|
+
});
|
|
117
|
+
expect(after.status).toBe(401);
|
|
118
|
+
const afterBody = (await after.json()) as { error?: { details?: { reason?: string } } };
|
|
119
|
+
expect(afterBody.error?.details?.reason).toBe("revoked");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("POST /auth/logout flips the DB row and invalidates the JWT", async () => {
|
|
123
|
+
await h.seedUser("logout@example.com", "pw-long-enough");
|
|
124
|
+
const { token, sid } = await h.login("logout@example.com", "pw-long-enough");
|
|
125
|
+
|
|
126
|
+
const logoutRes = await h.authedPost("/api/auth/logout", token);
|
|
127
|
+
expect(logoutRes.status).toBe(200);
|
|
128
|
+
|
|
129
|
+
const rows = await stack.db.select().from(userSessionTable);
|
|
130
|
+
expect(rows[0]?.["id"]).toBe(sid);
|
|
131
|
+
expect(rows[0]?.["revokedAt"]).not.toBeNull();
|
|
132
|
+
|
|
133
|
+
const next = await h.authedPost("/api/query", token, {
|
|
134
|
+
type: "user:query:user:me",
|
|
135
|
+
payload: {},
|
|
136
|
+
});
|
|
137
|
+
expect(next.status).toBe(401);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// A user-initiated revoke against their own already-revoked sid gets a
|
|
141
|
+
// distinct already_revoked response so UIs can show "logged out at <time>"
|
|
142
|
+
// instead of the generic ownership_denied. Also asserts that the original
|
|
143
|
+
// revokedAt timestamp is preserved — the isNull-guard on the handler's
|
|
144
|
+
// UPDATE keeps the audit trail intact.
|
|
145
|
+
test("revoking an already-revoked sid → already_revoked + preserves original revokedAt", async () => {
|
|
146
|
+
await h.seedUser("double@example.com", "pw-long-enough");
|
|
147
|
+
const first = await h.login("double@example.com", "pw-long-enough");
|
|
148
|
+
|
|
149
|
+
// First revoke succeeds — stamps revokedAt = t1.
|
|
150
|
+
const firstRevoke = await h.authedPost("/api/write", first.token, {
|
|
151
|
+
type: SessionHandlers.revoke,
|
|
152
|
+
payload: { id: first.sid },
|
|
153
|
+
});
|
|
154
|
+
expect(firstRevoke.status).toBe(200);
|
|
155
|
+
|
|
156
|
+
const [rowAfterFirst] = await stack.db
|
|
157
|
+
.select()
|
|
158
|
+
.from(userSessionTable)
|
|
159
|
+
.where(eq(userSessionTable["id"], first.sid));
|
|
160
|
+
const originalRevokedAt = rowAfterFirst?.["revokedAt"] as Temporal.Instant | null;
|
|
161
|
+
expect(originalRevokedAt).not.toBeNull();
|
|
162
|
+
|
|
163
|
+
// Fresh login for the same user — new sid, new token. Hit the handler
|
|
164
|
+
// via the PRODUCTION auth path (no bypass-JWT hackery) and try to
|
|
165
|
+
// revoke the OLD, already-revoked sid.
|
|
166
|
+
const second = await h.login("double@example.com", "pw-long-enough");
|
|
167
|
+
const retry = await h.authedPost("/api/write", second.token, {
|
|
168
|
+
type: SessionHandlers.revoke,
|
|
169
|
+
payload: { id: first.sid },
|
|
170
|
+
});
|
|
171
|
+
expect(retry.status).toBe(422);
|
|
172
|
+
const body = (await retry.json()) as { error?: { details?: { reason?: string } } };
|
|
173
|
+
expect(body.error?.details?.reason).toBe("session_already_revoked");
|
|
174
|
+
|
|
175
|
+
// Audit: the retry must NOT have touched the row. Same timestamp as t1.
|
|
176
|
+
const [rowAfterRetry] = await stack.db
|
|
177
|
+
.select()
|
|
178
|
+
.from(userSessionTable)
|
|
179
|
+
.where(eq(userSessionTable["id"], first.sid));
|
|
180
|
+
const preservedRevokedAt = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
|
|
181
|
+
expect(preservedRevokedAt?.epochMilliseconds).toBe(originalRevokedAt?.epochMilliseconds);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("session:mine only returns live sessions, marks the current one", async () => {
|
|
185
|
+
await h.seedUser("mine@example.com", "pw-long-enough");
|
|
186
|
+
// Three logins = three sessions (think: browser, mobile app, tablet)
|
|
187
|
+
const a = await h.login("mine@example.com", "pw-long-enough");
|
|
188
|
+
const _b = await h.login("mine@example.com", "pw-long-enough");
|
|
189
|
+
const c = await h.login("mine@example.com", "pw-long-enough");
|
|
190
|
+
|
|
191
|
+
// Revoke the middle one so we can assert the list hides revoked rows
|
|
192
|
+
await stack.http.raw(
|
|
193
|
+
"POST",
|
|
194
|
+
"/api/write",
|
|
195
|
+
{ type: SessionHandlers.revoke, payload: { id: _b.sid } },
|
|
196
|
+
{ Authorization: `Bearer ${a.token}` },
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const listRes = await h.authedPost("/api/query", c.token, {
|
|
200
|
+
type: SessionQueries.mine,
|
|
201
|
+
payload: {},
|
|
202
|
+
});
|
|
203
|
+
expect(listRes.status).toBe(200);
|
|
204
|
+
const body = (await listRes.json()) as {
|
|
205
|
+
data: Array<{ id: string; current: boolean }>;
|
|
206
|
+
};
|
|
207
|
+
const ids = body.data.map((r) => r.id);
|
|
208
|
+
// Order: most-recently-created first. c was the last login, so it
|
|
209
|
+
// should lead; a (the first login) trails. Pinning the order stops a
|
|
210
|
+
// silent orderBy removal from slipping through.
|
|
211
|
+
expect(ids).toEqual([c.sid, a.sid]);
|
|
212
|
+
expect(ids).not.toContain(_b.sid);
|
|
213
|
+
|
|
214
|
+
// The caller's OWN sid is flagged as current
|
|
215
|
+
const currentRow = body.data.find((r) => r.current);
|
|
216
|
+
expect(currentRow?.id).toBe(c.sid);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("session:revoke-all-others keeps the caller's session alive", async () => {
|
|
220
|
+
await h.seedUser("nuke@example.com", "pw-long-enough");
|
|
221
|
+
const a = await h.login("nuke@example.com", "pw-long-enough");
|
|
222
|
+
const b = await h.login("nuke@example.com", "pw-long-enough");
|
|
223
|
+
const c = await h.login("nuke@example.com", "pw-long-enough");
|
|
224
|
+
|
|
225
|
+
// Caller is session b; revoke everything else
|
|
226
|
+
const res = await h.authedPost("/api/write", b.token, {
|
|
227
|
+
type: SessionHandlers.revokeAllOthers,
|
|
228
|
+
payload: {},
|
|
229
|
+
});
|
|
230
|
+
expect(res.status).toBe(200);
|
|
231
|
+
const body = (await res.json()) as { data: { count: number } };
|
|
232
|
+
expect(body.data.count).toBe(2); // a + c
|
|
233
|
+
|
|
234
|
+
// b's JWT still works
|
|
235
|
+
const still = await h.authedPost("/api/query", b.token, {
|
|
236
|
+
type: "user:query:user:me",
|
|
237
|
+
payload: {},
|
|
238
|
+
});
|
|
239
|
+
expect(still.status).toBe(200);
|
|
240
|
+
|
|
241
|
+
// a and c are out
|
|
242
|
+
const outA = await h.authedPost("/api/query", a.token, {
|
|
243
|
+
type: "user:query:user:me",
|
|
244
|
+
payload: {},
|
|
245
|
+
});
|
|
246
|
+
expect(outA.status).toBe(401);
|
|
247
|
+
const outC = await h.authedPost("/api/query", c.token, {
|
|
248
|
+
type: "user:query:user:me",
|
|
249
|
+
payload: {},
|
|
250
|
+
});
|
|
251
|
+
expect(outC.status).toBe(401);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a user cannot revoke another user's session — ownership_denied", async () => {
|
|
255
|
+
await h.seedUser("alice@example.com", "pw-long-enough");
|
|
256
|
+
await h.seedUser("mallory@example.com", "pw-long-enough");
|
|
257
|
+
|
|
258
|
+
const alice = await h.login("alice@example.com", "pw-long-enough");
|
|
259
|
+
const mallory = await h.login("mallory@example.com", "pw-long-enough");
|
|
260
|
+
|
|
261
|
+
// Mallory tries to revoke Alice's sid — fail-loud per H.2 convention
|
|
262
|
+
const res = await h.authedPost("/api/write", mallory.token, {
|
|
263
|
+
type: SessionHandlers.revoke,
|
|
264
|
+
payload: { id: alice.sid },
|
|
265
|
+
});
|
|
266
|
+
expect(res.status).toBe(422);
|
|
267
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
268
|
+
expect(body.error?.details?.reason).toBe("ownership_denied");
|
|
269
|
+
|
|
270
|
+
// Alice's session is still live
|
|
271
|
+
const aliceStillIn = await h.authedPost("/api/query", alice.token, {
|
|
272
|
+
type: "user:query:user:me",
|
|
273
|
+
payload: {},
|
|
274
|
+
});
|
|
275
|
+
expect(aliceStillIn.status).toBe(200);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("revoking an unknown sid returns the SAME ownership_denied — no existence oracle", async () => {
|
|
279
|
+
await h.seedUser("eve@example.com", "pw-long-enough");
|
|
280
|
+
const eve = await h.login("eve@example.com", "pw-long-enough");
|
|
281
|
+
|
|
282
|
+
// Well-formed UUID that doesn't exist in user_sessions
|
|
283
|
+
const res = await h.authedPost("/api/write", eve.token, {
|
|
284
|
+
type: SessionHandlers.revoke,
|
|
285
|
+
payload: { id: "00000000-0000-4000-8000-0000deadbeef" },
|
|
286
|
+
});
|
|
287
|
+
expect(res.status).toBe(422);
|
|
288
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
289
|
+
expect(body.error?.details?.reason).toBe("ownership_denied");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("revoke-all-others on a sidless JWT refuses loudly (session_required)", async () => {
|
|
293
|
+
const { userId } = await h.seedUser("sidless@example.com", "pw-long-enough");
|
|
294
|
+
|
|
295
|
+
// Hand-sign a JWT without jti — simulates a stateless-JWT deployment
|
|
296
|
+
// or a rolling-deploy gap. "sign out everywhere else" is ill-defined
|
|
297
|
+
// without knowing which session is "current", so refuse.
|
|
298
|
+
const tokenNoSid = await stack.jwt.sign({ id: userId, tenantId: TENANT, roles: ["User"] });
|
|
299
|
+
|
|
300
|
+
const res = await stack.http.raw(
|
|
301
|
+
"POST",
|
|
302
|
+
"/api/write",
|
|
303
|
+
{ type: SessionHandlers.revokeAllOthers, payload: {} },
|
|
304
|
+
{ Authorization: `Bearer ${tokenNoSid}` },
|
|
305
|
+
);
|
|
306
|
+
expect(res.status).toBe(422);
|
|
307
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
308
|
+
expect(body.error?.details?.reason).toBe("session_required");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("N concurrent revoke+logout requests on the same sid converge safely", async () => {
|
|
312
|
+
// Fire many pairs of revoke + logout against one sid and verify:
|
|
313
|
+
// (a) no call 5xxs,
|
|
314
|
+
// (b) the revocation timestamp is stable (exactly one racer's write
|
|
315
|
+
// wins, later racers no-op thanks to the isNull-guard),
|
|
316
|
+
// (c) the token is dead afterwards.
|
|
317
|
+
// The expected 4xx mix: middleware 401 for requests whose JWT-check
|
|
318
|
+
// sees the already-revoked sid, or handler 422 `already_revoked` for
|
|
319
|
+
// revoke calls that make it past the middleware but find the row
|
|
320
|
+
// already flipped. Either is correct behaviour.
|
|
321
|
+
await h.seedUser("race@example.com", "pw-long-enough");
|
|
322
|
+
const { token, sid } = await h.login("race@example.com", "pw-long-enough");
|
|
323
|
+
|
|
324
|
+
const RACES = 8;
|
|
325
|
+
const pairs = Array.from({ length: RACES }, () => [
|
|
326
|
+
h.authedPost("/api/write", token, {
|
|
327
|
+
type: SessionHandlers.revoke,
|
|
328
|
+
payload: { id: sid },
|
|
329
|
+
}),
|
|
330
|
+
h.authedPost("/api/auth/logout", token),
|
|
331
|
+
]).flat();
|
|
332
|
+
const results = await Promise.all(pairs);
|
|
333
|
+
|
|
334
|
+
for (const res of results) {
|
|
335
|
+
expect(res.status).toBeLessThan(500);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Capture revokedAt once straight after the race, then after a short
|
|
339
|
+
// delay + another revoke attempt via a fresh login (production path —
|
|
340
|
+
// not a bypass hack). If the audit-guard were missing, the second
|
|
341
|
+
// readout would move forward because one of the late racers would
|
|
342
|
+
// have overwritten t1.
|
|
343
|
+
const [row] = await stack.db
|
|
344
|
+
.select()
|
|
345
|
+
.from(userSessionTable)
|
|
346
|
+
.where(eq(userSessionTable["id"], sid));
|
|
347
|
+
const tAfterRace = row?.["revokedAt"] as Temporal.Instant | null;
|
|
348
|
+
expect(tAfterRace).not.toBeNull();
|
|
349
|
+
|
|
350
|
+
// Fresh login → new sid → try to revoke the old sid once more. Handler
|
|
351
|
+
// will 422 already_revoked; the DB row must not move.
|
|
352
|
+
const fresh = await h.login("race@example.com", "pw-long-enough");
|
|
353
|
+
const retry = await h.authedPost("/api/write", fresh.token, {
|
|
354
|
+
type: SessionHandlers.revoke,
|
|
355
|
+
payload: { id: sid },
|
|
356
|
+
});
|
|
357
|
+
expect(retry.status).toBe(422);
|
|
358
|
+
|
|
359
|
+
const [rowAfterRetry] = await stack.db
|
|
360
|
+
.select()
|
|
361
|
+
.from(userSessionTable)
|
|
362
|
+
.where(eq(userSessionTable["id"], sid));
|
|
363
|
+
const tAfterRetry = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
|
|
364
|
+
expect(tAfterRetry?.epochMilliseconds).toBe(tAfterRace?.epochMilliseconds);
|
|
365
|
+
|
|
366
|
+
// Sanity: the original JWT is definitively dead now, no matter which
|
|
367
|
+
// racer won.
|
|
368
|
+
const after = await h.authedPost("/api/query", token, {
|
|
369
|
+
type: "user:query:user:me",
|
|
370
|
+
payload: {},
|
|
371
|
+
});
|
|
372
|
+
expect(after.status).toBe(401);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Middleware must reject "session row is gone" (attacker forged a sid, or
|
|
376
|
+
// the cleanup job deleted it) with a distinct reason so logs can tell the
|
|
377
|
+
// two branches apart from "revoked".
|
|
378
|
+
test("missing sid row → 401 with reason=missing", async () => {
|
|
379
|
+
await h.seedUser("ghost@example.com", "pw-long-enough");
|
|
380
|
+
const { token, sid } = await h.login("ghost@example.com", "pw-long-enough");
|
|
381
|
+
|
|
382
|
+
// Hard-delete the session row so it's gone from the store (as opposed to
|
|
383
|
+
// soft-revoked). The JWT stays syntactically valid.
|
|
384
|
+
await stack.db.delete(userSessionTable).where(eq(userSessionTable["id"], sid));
|
|
385
|
+
|
|
386
|
+
const res = await h.authedPost("/api/query", token, {
|
|
387
|
+
type: "user:query:user:me",
|
|
388
|
+
payload: {},
|
|
389
|
+
});
|
|
390
|
+
expect(res.status).toBe(401);
|
|
391
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
392
|
+
expect(body.error?.details?.reason).toBe("missing");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("expired session row → 401 with reason=expired", async () => {
|
|
396
|
+
await h.seedUser("stale@example.com", "pw-long-enough");
|
|
397
|
+
const { token, sid } = await h.login("stale@example.com", "pw-long-enough");
|
|
398
|
+
|
|
399
|
+
// Back-date expiresAt so the row is still present + not revoked, just
|
|
400
|
+
// past its window. Simulates what a long-lived JWT would hit.
|
|
401
|
+
await stack.db
|
|
402
|
+
.update(userSessionTable)
|
|
403
|
+
.set({ expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") })
|
|
404
|
+
.where(eq(userSessionTable["id"], sid));
|
|
405
|
+
|
|
406
|
+
const res = await h.authedPost("/api/query", token, {
|
|
407
|
+
type: "user:query:user:me",
|
|
408
|
+
payload: {},
|
|
409
|
+
});
|
|
410
|
+
expect(res.status).toBe(401);
|
|
411
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
412
|
+
expect(body.error?.details?.reason).toBe("expired");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Admin list — all sessions in the caller's tenant (including revoked).
|
|
416
|
+
// Regular Users get a 403; only admins may see other people's sessions.
|
|
417
|
+
// The Admin promotion goes through the production path (membership-row
|
|
418
|
+
// update + fresh login), so the test documents the real flow rather than
|
|
419
|
+
// a JWT shortcut.
|
|
420
|
+
test("session:list returns every session in the tenant for admins, 403 for users", async () => {
|
|
421
|
+
const { userId: aliceId } = await h.seedUser("alice2@example.com", "pw-long-enough");
|
|
422
|
+
await h.seedUser("bob2@example.com", "pw-long-enough");
|
|
423
|
+
const alice = await h.login("alice2@example.com", "pw-long-enough");
|
|
424
|
+
await h.login("bob2@example.com", "pw-long-enough");
|
|
425
|
+
|
|
426
|
+
// Alice is a plain User → access layer blocks the list query
|
|
427
|
+
const asUser = await h.authedPost("/api/query", alice.token, {
|
|
428
|
+
type: SessionQueries.list,
|
|
429
|
+
payload: {},
|
|
430
|
+
});
|
|
431
|
+
expect(asUser.status).toBe(403);
|
|
432
|
+
|
|
433
|
+
// Promote Alice to Admin via the tenant-memberships row, then re-login
|
|
434
|
+
// so she gets a fresh JWT with the new role in its claims. This is the
|
|
435
|
+
// actual production path — roles are tenant-membership data, not JWT
|
|
436
|
+
// metadata we can fiddle with directly.
|
|
437
|
+
await stack.db
|
|
438
|
+
.update(tenantMembershipsTable)
|
|
439
|
+
.set({ roles: JSON.stringify(["Admin"]) })
|
|
440
|
+
.where(
|
|
441
|
+
and(
|
|
442
|
+
eq(tenantMembershipsTable.userId, aliceId),
|
|
443
|
+
eq(tenantMembershipsTable.tenantId, TENANT),
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
const aliceAsAdmin = await h.login("alice2@example.com", "pw-long-enough");
|
|
447
|
+
|
|
448
|
+
const asAdmin = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
449
|
+
type: SessionQueries.list,
|
|
450
|
+
payload: {},
|
|
451
|
+
});
|
|
452
|
+
expect(asAdmin.status).toBe(200);
|
|
453
|
+
const body = (await asAdmin.json()) as {
|
|
454
|
+
data: Array<{
|
|
455
|
+
id: string;
|
|
456
|
+
userId: string;
|
|
457
|
+
createdAt: string;
|
|
458
|
+
revokedAt: string | null;
|
|
459
|
+
}>;
|
|
460
|
+
};
|
|
461
|
+
// Three rows total: Alice's pre-promotion session, Alice's post-promotion
|
|
462
|
+
// session, Bob's session. Two distinct users.
|
|
463
|
+
expect(body.data).toHaveLength(3);
|
|
464
|
+
const userIds = new Set(body.data.map((r) => r.userId));
|
|
465
|
+
expect(userIds.size).toBe(2);
|
|
466
|
+
|
|
467
|
+
// Order: most-recently-created first. aliceAsAdmin's session was the
|
|
468
|
+
// last login; aliceAsAdmin.sid leads the list. Pinning guards against
|
|
469
|
+
// silent orderBy removal.
|
|
470
|
+
expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared fixtures for session-related integration tests. Centralises the
|
|
2
|
+
// seed/login/request helpers while keeping per-suite state (stack, tenantId)
|
|
3
|
+
// explicit in the call site.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// const h = makeSessionHelpers(stack, TENANT);
|
|
7
|
+
// await h.seedUser("x@example.com", "pw");
|
|
8
|
+
// const { token, sid } = await h.login("x@example.com", "pw");
|
|
9
|
+
// const res = await h.authedPost("/api/query", token, { type, payload });
|
|
10
|
+
|
|
11
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import * as jose from "jose";
|
|
14
|
+
import { expect } from "vitest";
|
|
15
|
+
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
16
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
17
|
+
import { UserHandlers } from "../../user";
|
|
18
|
+
|
|
19
|
+
export type LoginResult = {
|
|
20
|
+
readonly token: string;
|
|
21
|
+
readonly sid: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Return type is inferred from the factory so callers just use
|
|
25
|
+
// `ReturnType<typeof makeSessionHelpers>` — no separate type export to
|
|
26
|
+
// keep in sync with the implementation. Params are typed inline on each
|
|
27
|
+
// method so the inference is sharp.
|
|
28
|
+
export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
|
|
29
|
+
return {
|
|
30
|
+
async seedUser(
|
|
31
|
+
email: string,
|
|
32
|
+
password: string,
|
|
33
|
+
opts?: { roles?: readonly string[] },
|
|
34
|
+
): Promise<{ userId: string }> {
|
|
35
|
+
const hash = await hashPassword(password);
|
|
36
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
37
|
+
UserHandlers.create,
|
|
38
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "u" },
|
|
39
|
+
TestUsers.systemAdmin,
|
|
40
|
+
);
|
|
41
|
+
await seedTenantMembership(stack.db, {
|
|
42
|
+
userId: created.id,
|
|
43
|
+
tenantId,
|
|
44
|
+
roles: opts?.roles ?? ["User"],
|
|
45
|
+
});
|
|
46
|
+
return { userId: created.id };
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async login(email: string, password: string): Promise<LoginResult> {
|
|
50
|
+
const res = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
const body = (await res.json()) as { token: string };
|
|
53
|
+
const payload = jose.decodeJwt(body.token);
|
|
54
|
+
const sid = payload.jti;
|
|
55
|
+
if (typeof sid !== "string") {
|
|
56
|
+
throw new Error("login did not emit a sid — is sessions wired?");
|
|
57
|
+
}
|
|
58
|
+
return { token: body.token, sid };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/** POST with `Authorization: Bearer ${token}`. Body is JSON-serialised. */
|
|
62
|
+
authedPost(path: string, token: string, body?: unknown): Promise<Response> {
|
|
63
|
+
return stack.http.raw("POST", path, body, { Authorization: `Bearer ${token}` });
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const SESSIONS_FEATURE = "sessions" as const;
|
|
2
|
+
|
|
3
|
+
// Qualified write handler names — entity prefix is "userSession", snake_case
|
|
4
|
+
// "user_session" on the wire.
|
|
5
|
+
export const SessionHandlers = {
|
|
6
|
+
revoke: "sessions:write:user-session:revoke",
|
|
7
|
+
revokeAllOthers: "sessions:write:user-session:revoke-all-others",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const SessionQueries = {
|
|
11
|
+
// User-scoped: "my live sessions" (other devices/browsers)
|
|
12
|
+
mine: "sessions:query:user-session:mine",
|
|
13
|
+
// Admin-scoped: all sessions in the caller's tenant (live + revoked).
|
|
14
|
+
// Tenant isolation comes from ctx.db; access-gate is admin-or-higher.
|
|
15
|
+
list: "sessions:query:user-session:list",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export const SessionErrors = {
|
|
19
|
+
// Returned by session:revoke when the sid is already revoked. Distinct
|
|
20
|
+
// from ownership_denied so the UI can say "this session was already
|
|
21
|
+
// logged out at <time>" instead of a generic access error. Revealing an
|
|
22
|
+
// already-revoked sid's state to its owner is not a leak (they already
|
|
23
|
+
// know it was their session).
|
|
24
|
+
alreadyRevoked: "session_already_revoked",
|
|
25
|
+
// "sign out everywhere else" called without a current session on the JWT.
|
|
26
|
+
// Stateless-JWT deployments can't express "everywhere else", so we refuse
|
|
27
|
+
// rather than interpret the request as "nuke everything including me".
|
|
28
|
+
sessionRequired: "session_required",
|
|
29
|
+
// Handler reuses the framework-wide ownership_denied reason (kept here as
|
|
30
|
+
// a constant so the handler's constructor-arg and the test's assertion
|
|
31
|
+
// read from the same source).
|
|
32
|
+
ownershipDenied: "ownership_denied",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
// Default cache TTL in milliseconds. 60s keeps DB load down while still
|
|
36
|
+
// surfacing revocations within a minute. Tests override to 0 for determinism.
|
|
37
|
+
export const DEFAULT_SESSION_CACHE_TTL_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
// Default session lifetime — 30 days. Mirrors typical "remember me" windows
|
|
40
|
+
// and matches the JWT 24h refresh story (JWT expires sooner, session lives
|
|
41
|
+
// longer so refresh can rotate the token without requiring a new password).
|
|
42
|
+
// MVP ships a single window; per-app overrides can come later.
|
|
43
|
+
export const DEFAULT_SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { cleanupJob } from "./handlers/cleanup.job";
|
|
3
|
+
import { listQuery } from "./handlers/list.query";
|
|
4
|
+
import { mineQuery } from "./handlers/mine.query";
|
|
5
|
+
import { revokeWrite } from "./handlers/revoke.write";
|
|
6
|
+
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
7
|
+
import { userSessionEntity } from "./schema/user-session";
|
|
8
|
+
import type { SessionMassRevoker } from "./session-callbacks";
|
|
9
|
+
|
|
10
|
+
export type SessionsFeatureOptions = {
|
|
11
|
+
// When wired, a successful update on the `user` entity that changes the
|
|
12
|
+
// `passwordHash` column triggers a mass-revoke of every live session for
|
|
13
|
+
// that user. Industry-standard "password-change signs you out everywhere"
|
|
14
|
+
// flow, including the session that did the change itself — the client has
|
|
15
|
+
// to re-login after a password change.
|
|
16
|
+
//
|
|
17
|
+
// Runs as an afterCommit postSave hook: the password-change commits first,
|
|
18
|
+
// then the sessions are revoked. Best-effort — if the mass-revoker throws,
|
|
19
|
+
// the password change is NOT rolled back (a password change with a stale
|
|
20
|
+
// session still wins over a user-visible error on the change itself).
|
|
21
|
+
readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The sessions feature registers the userSession entity and the three user-
|
|
25
|
+
// facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
|
|
26
|
+
// export a sessionCreator/sessionRevoker here — those are produced by
|
|
27
|
+
// `createSessionCallbacks()` at app-setup time and wired into
|
|
28
|
+
// `buildServer({ auth: { ... } })`.
|
|
29
|
+
//
|
|
30
|
+
// Why the split: handlers participate in the dispatcher pipeline (access
|
|
31
|
+
// checks, audit, hooks). The creator/revoker callbacks run on the hot
|
|
32
|
+
// login/request path and do direct-DB writes — threading them through the
|
|
33
|
+
// dispatcher would buy latency without added safety (the row columns ARE
|
|
34
|
+
// the audit trail).
|
|
35
|
+
//
|
|
36
|
+
// Not system-scoped: sessions live per tenant, and the handlers should only
|
|
37
|
+
// see rows in the caller's active tenant.
|
|
38
|
+
export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
|
|
39
|
+
return defineFeature("sessions", (r) => {
|
|
40
|
+
r.entity("user-session", userSessionEntity);
|
|
41
|
+
|
|
42
|
+
const handlers = {
|
|
43
|
+
revoke: r.writeHandler(revokeWrite),
|
|
44
|
+
revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const queries = {
|
|
48
|
+
mine: r.queryHandler(mineQuery),
|
|
49
|
+
list: r.queryHandler(listQuery),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Retention: chunked DELETE of expired/revoked rows. Manual trigger
|
|
53
|
+
// only so dev environments don't churn. Ops wires a cron in the app's
|
|
54
|
+
// dispatcher config when running a long-lived deployment.
|
|
55
|
+
r.job("cleanup", { trigger: { manual: true } }, cleanupJob);
|
|
56
|
+
|
|
57
|
+
// Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
|
|
58
|
+
// the supported cross-feature path: entity-keyed, not prefixed by the
|
|
59
|
+
// registering feature. Fires after every successful write on any
|
|
60
|
+
// user-entity handler; we only act when passwordHash is part of the
|
|
61
|
+
// changes-delta the handler was given.
|
|
62
|
+
//
|
|
63
|
+
// Checking `changes["passwordHash"] !== undefined` is cheaper and more
|
|
64
|
+
// correct than diffing data vs previous — "undefined in changes" means
|
|
65
|
+
// "the handler didn't touch this column", which is exactly the signal
|
|
66
|
+
// we want to skip on. Works for both direct user:update calls and any
|
|
67
|
+
// other handler that happens to write the column.
|
|
68
|
+
const autoRevoke = options?.autoRevokeOnPasswordChange;
|
|
69
|
+
if (autoRevoke) {
|
|
70
|
+
r.entityHook("postSave", "user", async (ctx) => {
|
|
71
|
+
// skip: brand-new user, no sessions can possibly exist yet. The
|
|
72
|
+
// initial passwordHash on a user:create would trip the second guard
|
|
73
|
+
// otherwise — every registration would do a mass-revoke roundtrip
|
|
74
|
+
// for a user who literally has no rows in user_sessions.
|
|
75
|
+
if (ctx.isNew) return;
|
|
76
|
+
// skip: handler didn't touch passwordHash, nothing to revoke
|
|
77
|
+
if (ctx.changes["passwordHash"] === undefined) return;
|
|
78
|
+
await autoRevoke(String(ctx.id));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { handlers, queries };
|
|
83
|
+
});
|
|
84
|
+
}
|