@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,109 @@
|
|
|
1
|
+
// Retention/cleanup job for user_sessions. Without this the table grows
|
|
2
|
+
// monotonically: every login adds one row, logout only flips revokedAt. A
|
|
3
|
+
// long-lived app would eventually accumulate millions of dead rows that
|
|
4
|
+
// slow the sessionChecker point-read down and bloat backups.
|
|
5
|
+
//
|
|
6
|
+
// Policy:
|
|
7
|
+
// - Delete rows whose expiresAt is older than `olderThanDays` (default
|
|
8
|
+
// 30d). Expired sessions can never go live again; the audit trail
|
|
9
|
+
// isn't useful past the retention window.
|
|
10
|
+
// - Delete rows whose revokedAt is older than `olderThanDays`. Same
|
|
11
|
+
// reasoning: a session revoked months ago has no operational value.
|
|
12
|
+
// - NEVER delete currently-live rows. Safe by construction — the WHERE
|
|
13
|
+
// clause requires either expiresAt OR revokedAt to be past-cutoff.
|
|
14
|
+
//
|
|
15
|
+
// Chunked DELETE (default 1000/batch) keeps lock durations bounded. Ops
|
|
16
|
+
// schedules this daily (manual trigger by default — opt-in to cron in the
|
|
17
|
+
// app's feature-wiring so a dev environment doesn't churn through a fresh
|
|
18
|
+
// seed). Mirror of secrets/retention.job in shape.
|
|
19
|
+
|
|
20
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
23
|
+
import { or, sql } from "drizzle-orm";
|
|
24
|
+
import { userSessionTable } from "../schema/user-session";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_OLDER_THAN_DAYS = 30;
|
|
27
|
+
const DEFAULT_BATCH_SIZE = 1000;
|
|
28
|
+
|
|
29
|
+
export type SessionCleanupPayload = {
|
|
30
|
+
readonly olderThanDays?: number;
|
|
31
|
+
readonly batchSize?: number;
|
|
32
|
+
readonly maxDurationMs?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SessionCleanupResult = {
|
|
36
|
+
readonly deleted: number;
|
|
37
|
+
readonly batchesProcessed: number;
|
|
38
|
+
readonly stoppedReason: "empty" | "timeout" | "signal";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
42
|
+
const payload = rawPayload as SessionCleanupPayload;
|
|
43
|
+
if (!ctx.db) {
|
|
44
|
+
throw new InternalError({
|
|
45
|
+
message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const db = ctx.db as DbConnection;
|
|
49
|
+
|
|
50
|
+
// Coerce-and-validate: BullMQ payloads arrive as opaque JSON, so TS types
|
|
51
|
+
// don't survive. Guard before the value is interpolated into SQL.
|
|
52
|
+
const olderThanDaysRaw = payload.olderThanDays ?? DEFAULT_OLDER_THAN_DAYS;
|
|
53
|
+
const olderThanDays = Number(olderThanDaysRaw);
|
|
54
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays < 0 || !Number.isInteger(olderThanDays)) {
|
|
55
|
+
throw new InternalError({
|
|
56
|
+
message: `[sessions:cleanup] olderThanDays must be a non-negative integer (got ${String(olderThanDaysRaw)})`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
60
|
+
const deadline = payload.maxDurationMs
|
|
61
|
+
? Date.now() + payload.maxDurationMs
|
|
62
|
+
: Number.POSITIVE_INFINITY;
|
|
63
|
+
|
|
64
|
+
const cutoff = sql`now() - (${olderThanDays} * interval '1 day')`;
|
|
65
|
+
|
|
66
|
+
let deleted = 0;
|
|
67
|
+
let batchesProcessed = 0;
|
|
68
|
+
let stoppedReason: SessionCleanupResult["stoppedReason"] = "empty";
|
|
69
|
+
|
|
70
|
+
while (true) {
|
|
71
|
+
if (ctx.signal?.aborted) {
|
|
72
|
+
stoppedReason = "signal";
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (Date.now() >= deadline) {
|
|
76
|
+
stoppedReason = "timeout";
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// DELETE-by-id-subquery with an explicit LIMIT so the lock stays short.
|
|
81
|
+
// The WHERE clause is the safety net: we only touch rows that are
|
|
82
|
+
// PAST-CUTOFF (expired OR revoked), never currently-live sessions. A
|
|
83
|
+
// null-check in PG semantics: `x < cutoff` already excludes null.
|
|
84
|
+
const rows = await db
|
|
85
|
+
.delete(userSessionTable)
|
|
86
|
+
.where(
|
|
87
|
+
sql`${userSessionTable["id"]} in (
|
|
88
|
+
select ${userSessionTable["id"]}
|
|
89
|
+
from ${userSessionTable}
|
|
90
|
+
where ${or(
|
|
91
|
+
sql`${userSessionTable["expiresAt"]} < ${cutoff}`,
|
|
92
|
+
sql`${userSessionTable["revokedAt"]} < ${cutoff}`,
|
|
93
|
+
)}
|
|
94
|
+
limit ${batchSize}
|
|
95
|
+
)`,
|
|
96
|
+
)
|
|
97
|
+
.returning({ id: userSessionTable["id"] });
|
|
98
|
+
|
|
99
|
+
if (rows.length === 0) break;
|
|
100
|
+
|
|
101
|
+
deleted += rows.length;
|
|
102
|
+
batchesProcessed++;
|
|
103
|
+
|
|
104
|
+
if (rows.length < batchSize) break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result: SessionCleanupResult = { deleted, batchesProcessed, stoppedReason };
|
|
108
|
+
ctx.log?.info?.(`[sessions:cleanup] complete: ${JSON.stringify(result)}`);
|
|
109
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { desc } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { userSessionTable } from "../schema/user-session";
|
|
5
|
+
|
|
6
|
+
// Admin view of every session in the active tenant. Tenant admins use this
|
|
7
|
+
// to investigate "who is logged in right now" or revoke a suspicious
|
|
8
|
+
// device. Unlike `session:mine` this does NOT filter by userId — it's the
|
|
9
|
+
// whole tenant. Tenant-scoping comes from ctx.db (TenantDb applies a tenant
|
|
10
|
+
// filter automatically on select from tables with a tenantId column), so
|
|
11
|
+
// cross-tenant bleed is impossible.
|
|
12
|
+
//
|
|
13
|
+
// Includes revoked rows too — distinct column in the response tells the UI
|
|
14
|
+
// which entries are historical vs. live. The default ordering puts the
|
|
15
|
+
// newest first so a security review starts at the recent activity.
|
|
16
|
+
export const listQuery = defineQueryHandler({
|
|
17
|
+
name: "user-session:list",
|
|
18
|
+
schema: z.object({}),
|
|
19
|
+
access: { roles: access.admin },
|
|
20
|
+
handler: async (_query, ctx) => {
|
|
21
|
+
const rows = await ctx.db
|
|
22
|
+
.select({
|
|
23
|
+
id: userSessionTable["id"],
|
|
24
|
+
userId: userSessionTable["userId"],
|
|
25
|
+
createdAt: userSessionTable["createdAt"],
|
|
26
|
+
expiresAt: userSessionTable["expiresAt"],
|
|
27
|
+
revokedAt: userSessionTable["revokedAt"],
|
|
28
|
+
ip: userSessionTable["ip"],
|
|
29
|
+
userAgent: userSessionTable["userAgent"],
|
|
30
|
+
})
|
|
31
|
+
.from(userSessionTable)
|
|
32
|
+
.orderBy(desc(userSessionTable["createdAt"]));
|
|
33
|
+
return rows;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { and, desc, eq, isNull } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { userSessionTable } from "../schema/user-session";
|
|
5
|
+
|
|
6
|
+
// "My live sessions" — the backing data for a devices/sessions UI. Returns
|
|
7
|
+
// ONLY the current user's own, currently-live sessions, ordered by most-
|
|
8
|
+
// recently-used first. Revoked rows are excluded (they survive in DB for
|
|
9
|
+
// audit but the UI shouldn't show them as active).
|
|
10
|
+
//
|
|
11
|
+
// Note the `current` marker: we compare against the caller's `user.sid` so
|
|
12
|
+
// the UI can label the entry the user is looking at ("this device"). A user
|
|
13
|
+
// without a sid (stateless-JWT deployment) will simply see `current: false`
|
|
14
|
+
// on every row — the feature still works, just without the marker.
|
|
15
|
+
export const mineQuery = defineQueryHandler({
|
|
16
|
+
name: "user-session:mine",
|
|
17
|
+
schema: z.object({}),
|
|
18
|
+
access: { openToAll: true },
|
|
19
|
+
handler: async (query, ctx) => {
|
|
20
|
+
const rows = await ctx.db
|
|
21
|
+
.select({
|
|
22
|
+
id: userSessionTable["id"],
|
|
23
|
+
createdAt: userSessionTable["createdAt"],
|
|
24
|
+
expiresAt: userSessionTable["expiresAt"],
|
|
25
|
+
ip: userSessionTable["ip"],
|
|
26
|
+
userAgent: userSessionTable["userAgent"],
|
|
27
|
+
})
|
|
28
|
+
.from(userSessionTable)
|
|
29
|
+
.where(
|
|
30
|
+
and(eq(userSessionTable["userId"], query.user.id), isNull(userSessionTable["revokedAt"])),
|
|
31
|
+
)
|
|
32
|
+
.orderBy(desc(userSessionTable["createdAt"]));
|
|
33
|
+
|
|
34
|
+
const currentSid = query.user.sid;
|
|
35
|
+
return rows.map((r) => ({ ...r, current: currentSid === r["id"] }));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { and, eq, isNull, ne } from "drizzle-orm";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SessionErrors } from "../constants";
|
|
7
|
+
import { userSessionTable } from "../schema/user-session";
|
|
8
|
+
|
|
9
|
+
// "Sign out everywhere else" — keep the caller's current session, kill all
|
|
10
|
+
// other live sessions for this user. Requires `user.sid` so "keep current"
|
|
11
|
+
// is well-defined; without it we refuse loudly rather than silently nuking
|
|
12
|
+
// the caller's own session along with the others.
|
|
13
|
+
export const revokeAllOthersWrite = defineWriteHandler({
|
|
14
|
+
name: "user-session:revoke-all-others",
|
|
15
|
+
schema: z.object({}),
|
|
16
|
+
access: { openToAll: true },
|
|
17
|
+
handler: async (event, ctx) => {
|
|
18
|
+
const keepSid = event.user.sid;
|
|
19
|
+
if (!keepSid) {
|
|
20
|
+
return writeFailure(
|
|
21
|
+
new UnprocessableError(SessionErrors.sessionRequired, {
|
|
22
|
+
i18nKey: "sessions.errors.sessionRequired",
|
|
23
|
+
details: { userId: event.user.id },
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const updated = await ctx.db
|
|
29
|
+
.update(userSessionTable)
|
|
30
|
+
.set({ revokedAt: Temporal.Now.instant() })
|
|
31
|
+
.where(
|
|
32
|
+
and(
|
|
33
|
+
eq(userSessionTable["userId"], event.user.id),
|
|
34
|
+
isNull(userSessionTable["revokedAt"]),
|
|
35
|
+
ne(userSessionTable["id"], keepSid),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
.returning();
|
|
39
|
+
|
|
40
|
+
return { isSuccess: true, data: { count: updated.length } };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SessionErrors } from "../constants";
|
|
7
|
+
import { userSessionTable } from "../schema/user-session";
|
|
8
|
+
|
|
9
|
+
// Revoke a single session by id (= JWT jti). Three distinguishable outcomes:
|
|
10
|
+
//
|
|
11
|
+
// - Success: row existed, belonged to the caller, was live → revokedAt
|
|
12
|
+
// stamped to now().
|
|
13
|
+
// - already_revoked: row existed, belonged to the caller, was ALREADY
|
|
14
|
+
// revoked → distinct error so UIs can show "logged out at <time>"
|
|
15
|
+
// instead of a generic access-denied. Audit's original revokedAt is
|
|
16
|
+
// preserved (isNull-guard on the UPDATE).
|
|
17
|
+
// - ownership_denied: row didn't exist OR belonged to another user. Same
|
|
18
|
+
// response for both branches = no existence oracle for other users' sids.
|
|
19
|
+
//
|
|
20
|
+
// Try the UPDATE first with the full constraint set (id + userId + live);
|
|
21
|
+
// if it touches zero rows, a second SELECT disambiguates the reason. The
|
|
22
|
+
// second roundtrip only happens on the error path — success stays single-
|
|
23
|
+
// roundtrip.
|
|
24
|
+
export const revokeWrite = defineWriteHandler({
|
|
25
|
+
name: "user-session:revoke",
|
|
26
|
+
schema: z.object({
|
|
27
|
+
id: z.uuid(),
|
|
28
|
+
}),
|
|
29
|
+
access: { openToAll: true },
|
|
30
|
+
handler: async (event, ctx) => {
|
|
31
|
+
const updated = await ctx.db
|
|
32
|
+
.update(userSessionTable)
|
|
33
|
+
.set({ revokedAt: Temporal.Now.instant() })
|
|
34
|
+
.where(
|
|
35
|
+
and(
|
|
36
|
+
eq(userSessionTable["id"], event.payload.id),
|
|
37
|
+
eq(userSessionTable["userId"], event.user.id),
|
|
38
|
+
isNull(userSessionTable["revokedAt"]),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
.returning();
|
|
42
|
+
|
|
43
|
+
if (updated.length > 0) {
|
|
44
|
+
return { isSuccess: true, data: { id: event.payload.id } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Zero rows touched — disambiguate between "not yours" and "already
|
|
48
|
+
// revoked" via a point-read. Only hits on the error path.
|
|
49
|
+
const [row] = await ctx.db
|
|
50
|
+
.select({ userId: userSessionTable["userId"], revokedAt: userSessionTable["revokedAt"] })
|
|
51
|
+
.from(userSessionTable)
|
|
52
|
+
.where(eq(userSessionTable["id"], event.payload.id))
|
|
53
|
+
.limit(1);
|
|
54
|
+
|
|
55
|
+
if (row && row["userId"] === event.user.id && row["revokedAt"] !== null) {
|
|
56
|
+
return writeFailure(
|
|
57
|
+
new UnprocessableError(SessionErrors.alreadyRevoked, {
|
|
58
|
+
i18nKey: "sessions.errors.alreadyRevoked",
|
|
59
|
+
details: { id: event.payload.id },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return writeFailure(
|
|
65
|
+
new UnprocessableError(SessionErrors.ownershipDenied, {
|
|
66
|
+
i18nKey: "errors.ownershipDenied",
|
|
67
|
+
details: {
|
|
68
|
+
scope: "entity",
|
|
69
|
+
entityName: "user-session",
|
|
70
|
+
action: "revoke",
|
|
71
|
+
userId: event.user.id,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_SESSION_CACHE_TTL_MS,
|
|
3
|
+
DEFAULT_SESSION_EXPIRY_MS,
|
|
4
|
+
SESSIONS_FEATURE,
|
|
5
|
+
SessionErrors,
|
|
6
|
+
SessionHandlers,
|
|
7
|
+
SessionQueries,
|
|
8
|
+
} from "./constants";
|
|
9
|
+
export type { SessionsFeatureOptions } from "./feature";
|
|
10
|
+
export { createSessionsFeature } from "./feature";
|
|
11
|
+
export { userSessionEntity, userSessionTable } from "./schema/user-session";
|
|
12
|
+
export type {
|
|
13
|
+
SessionCallbacks,
|
|
14
|
+
SessionCallbacksOptions,
|
|
15
|
+
SessionMassRevoker,
|
|
16
|
+
} from "./session-callbacks";
|
|
17
|
+
export { createSessionCallbacks } from "./session-callbacks";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
createTimestampField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
// userSession — one row per signed-in client (browser tab, mobile app).
|
|
10
|
+
// The PRIMARY key is the sid — generated by the framework at login time and
|
|
11
|
+
// embedded in the JWT's `jti` claim. Using sid-as-PK means the auth middleware
|
|
12
|
+
// can look a session up with a single point-read instead of a (userId, sid)
|
|
13
|
+
// lookup, which matters because the check runs on every authenticated request.
|
|
14
|
+
//
|
|
15
|
+
// Fields that are system-managed (userId, revokedAt, expiresAt, etc.) are
|
|
16
|
+
// write-locked to the privileged role so the feature's handlers can still
|
|
17
|
+
// mutate them via ctx.db inside a transaction, but no user request can bypass
|
|
18
|
+
// the revocation flow by poking the column directly.
|
|
19
|
+
export const userSessionEntity = createEntity({
|
|
20
|
+
// Entity-Key ist "user-session" (mit Dash), toTableName's snake-case-
|
|
21
|
+
// Transform käme auf "read_user-sessions" → kein valides SQL ohne Quoting.
|
|
22
|
+
// Deshalb expliziter Override auf den read_-konformen Namen.
|
|
23
|
+
table: "read_user_sessions",
|
|
24
|
+
// sid-as-PK: the sessionCreator callback generates the UUID, returns it to
|
|
25
|
+
// the framework; the framework stamps it as `jti`; here the same value is
|
|
26
|
+
// the row primary key. Single source of truth for the identifier.
|
|
27
|
+
// No softDelete: revocation is its own lifecycle (revokedAt timestamp), not
|
|
28
|
+
// a delete — we want to keep the audit trail of revoked sessions for the
|
|
29
|
+
// "your devices" UI ("signed out 3 days ago").
|
|
30
|
+
softDelete: false,
|
|
31
|
+
fields: {
|
|
32
|
+
userId: createTextField({
|
|
33
|
+
required: true,
|
|
34
|
+
maxLength: 36,
|
|
35
|
+
access: { write: access.privileged },
|
|
36
|
+
}),
|
|
37
|
+
tenantId: createTextField({
|
|
38
|
+
required: true,
|
|
39
|
+
maxLength: 36,
|
|
40
|
+
access: { write: access.privileged },
|
|
41
|
+
}),
|
|
42
|
+
createdAt: createTimestampField({
|
|
43
|
+
required: true,
|
|
44
|
+
access: { write: access.privileged },
|
|
45
|
+
}),
|
|
46
|
+
expiresAt: createTimestampField({
|
|
47
|
+
required: true,
|
|
48
|
+
access: { write: access.privileged },
|
|
49
|
+
}),
|
|
50
|
+
// Set when the session is revoked (logout, switch-tenant, password-change,
|
|
51
|
+
// admin action). Middleware treats `revokedAt != null` as "blocked" and
|
|
52
|
+
// returns 401; the row sticks around for the audit trail.
|
|
53
|
+
revokedAt: createTimestampField({
|
|
54
|
+
access: { write: access.privileged },
|
|
55
|
+
}),
|
|
56
|
+
ip: createTextField({
|
|
57
|
+
maxLength: 64,
|
|
58
|
+
access: { write: access.privileged },
|
|
59
|
+
}),
|
|
60
|
+
userAgent: createTextField({
|
|
61
|
+
maxLength: 512,
|
|
62
|
+
access: { write: access.privileged },
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const userSessionTable = buildDrizzleTable("user_session", userSessionEntity);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthSessionStatus,
|
|
3
|
+
SessionChecker,
|
|
4
|
+
SessionCreator,
|
|
5
|
+
SessionMetadata,
|
|
6
|
+
SessionRevoker,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
8
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
11
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
12
|
+
import { Temporal } from "temporal-polyfill";
|
|
13
|
+
import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
|
|
14
|
+
import { userSessionTable } from "./schema/user-session";
|
|
15
|
+
|
|
16
|
+
// Why the callbacks live at the raw-DB level rather than going through the
|
|
17
|
+
// dispatcher: session-create/revoke/check run on the hot path of every
|
|
18
|
+
// login and every request. The (createdAt/revokedAt/ip/userAgent) columns
|
|
19
|
+
// already are the audit trail — a dispatcher roundtrip buys nothing.
|
|
20
|
+
|
|
21
|
+
// Mass-revoke for a single user. Used by the password-change hook and
|
|
22
|
+
// "sign out everywhere" flows. Returns the count of rows flipped so a
|
|
23
|
+
// caller can log "revoked N other sessions".
|
|
24
|
+
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
25
|
+
|
|
26
|
+
export type SessionCallbacksOptions = {
|
|
27
|
+
readonly db: DbConnection;
|
|
28
|
+
// Session lifetime. MVP uses a single flat window; per-app policies can
|
|
29
|
+
// come later (e.g. longer for "remember me", shorter for admin).
|
|
30
|
+
readonly expiresInMs?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SessionCallbacks = {
|
|
34
|
+
sessionCreator: SessionCreator;
|
|
35
|
+
sessionRevoker: SessionRevoker;
|
|
36
|
+
sessionChecker: SessionChecker;
|
|
37
|
+
sessionMassRevoker: SessionMassRevoker;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCallbacks {
|
|
41
|
+
const ttlMs = opts.expiresInMs ?? DEFAULT_SESSION_EXPIRY_MS;
|
|
42
|
+
const { db } = opts;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async sessionCreator(user: SessionUser, meta: SessionMetadata): Promise<string> {
|
|
46
|
+
const sid = generateId();
|
|
47
|
+
const now = Temporal.Now.instant();
|
|
48
|
+
const expiresAt = now.add({ milliseconds: ttlMs });
|
|
49
|
+
await db.insert(userSessionTable).values({
|
|
50
|
+
id: sid,
|
|
51
|
+
tenantId: user.tenantId,
|
|
52
|
+
userId: user.id,
|
|
53
|
+
createdAt: now,
|
|
54
|
+
expiresAt,
|
|
55
|
+
ip: meta.ip,
|
|
56
|
+
userAgent: meta.userAgent,
|
|
57
|
+
});
|
|
58
|
+
return sid;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async sessionRevoker(sid: string): Promise<void> {
|
|
62
|
+
// Audit-preserving: `isNull(revokedAt)` in WHERE means a second call
|
|
63
|
+
// on an already-revoked sid is a no-op instead of overwriting the
|
|
64
|
+
// original timestamp. Double-revoke races land here via logout +
|
|
65
|
+
// switch-tenant on the same sid. (Password-change uses a different
|
|
66
|
+
// callback — sessionMassRevoker — and isn't in scope for this guard.)
|
|
67
|
+
await db
|
|
68
|
+
.update(userSessionTable)
|
|
69
|
+
.set({ revokedAt: Temporal.Now.instant() })
|
|
70
|
+
.where(and(eq(userSessionTable.id, sid), isNull(userSessionTable.revokedAt)));
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionStatus> {
|
|
74
|
+
const rows = await db
|
|
75
|
+
.select({
|
|
76
|
+
userId: userSessionTable.userId,
|
|
77
|
+
revokedAt: userSessionTable.revokedAt,
|
|
78
|
+
expiresAt: userSessionTable.expiresAt,
|
|
79
|
+
})
|
|
80
|
+
.from(userSessionTable)
|
|
81
|
+
.where(eq(userSessionTable.id, sid))
|
|
82
|
+
.limit(1);
|
|
83
|
+
const row = rows[0];
|
|
84
|
+
if (!row) return "missing";
|
|
85
|
+
// Cross-user check: if the sid belongs to someone else, treat it
|
|
86
|
+
// identically to "missing" so a compromised sid paired with a valid
|
|
87
|
+
// JWT from a different user gets the same opaque response as a
|
|
88
|
+
// forged sid. No existence oracle on other users' sids.
|
|
89
|
+
if (row.userId !== expectedUserId) return "missing";
|
|
90
|
+
if (row.revokedAt !== null) return "revoked";
|
|
91
|
+
// Temporal-native clock read (Sprint F migration) — keeps the feature
|
|
92
|
+
// free of raw Date.now() for consistency with the rest of the codebase.
|
|
93
|
+
if (row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds) {
|
|
94
|
+
return "expired";
|
|
95
|
+
}
|
|
96
|
+
return "live";
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async sessionMassRevoker(userId: string): Promise<number> {
|
|
100
|
+
// Count is accurate because we only touch live rows — a previously
|
|
101
|
+
// revoked row stays in its state and isn't double-counted.
|
|
102
|
+
const result = await db
|
|
103
|
+
.update(userSessionTable)
|
|
104
|
+
.set({ revokedAt: Temporal.Now.instant() })
|
|
105
|
+
.where(and(eq(userSessionTable.userId, userId), isNull(userSessionTable.revokedAt)))
|
|
106
|
+
.returning({ id: userSessionTable.id });
|
|
107
|
+
return result.length;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Testing helpers for the sessions feature. The factory below turns a
|
|
2
|
+
// `LateBoundHolder<SessionCallbacks>` into the two shapes a test needs:
|
|
3
|
+
//
|
|
4
|
+
// const holder = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
5
|
+
// const bound = sessionCallbacksFromLateBound(holder);
|
|
6
|
+
//
|
|
7
|
+
// stack = await setupTestStack({
|
|
8
|
+
// features: [..., createSessionsFeature({
|
|
9
|
+
// autoRevokeOnPasswordChange: bound.asMassRevoker(),
|
|
10
|
+
// })],
|
|
11
|
+
// authConfig: { ...bound.asAuthConfig(), membershipQuery, loginHandler },
|
|
12
|
+
// });
|
|
13
|
+
// holder.set(createSessionCallbacks({ db: stack.db }));
|
|
14
|
+
//
|
|
15
|
+
// Why the helper lives in bundled-features/sessions rather than framework/testing:
|
|
16
|
+
// it closes over `AuthRoutesConfig` + `SessionCallbacks`, both of which the
|
|
17
|
+
// sessions feature owns. framework/testing only provides the generic
|
|
18
|
+
// `createLateBoundHolder<T>` — shape-independent.
|
|
19
|
+
|
|
20
|
+
import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
|
|
21
|
+
import type { LateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
|
+
import type { SessionCallbacks, SessionMassRevoker } from "./session-callbacks";
|
|
23
|
+
|
|
24
|
+
export type BoundSessionCallbacks = {
|
|
25
|
+
/** auth-config fragment: creator + revoker + checker, all late-bound. */
|
|
26
|
+
asAuthConfig(): Pick<AuthRoutesConfig, "sessionCreator" | "sessionRevoker" | "sessionChecker">;
|
|
27
|
+
/** mass-revoker function for sessionsFeature({ autoRevokeOnPasswordChange }). */
|
|
28
|
+
asMassRevoker(): SessionMassRevoker;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function sessionCallbacksFromLateBound(
|
|
32
|
+
holder: LateBoundHolder<SessionCallbacks>,
|
|
33
|
+
): BoundSessionCallbacks {
|
|
34
|
+
return {
|
|
35
|
+
asAuthConfig: () => ({
|
|
36
|
+
sessionCreator: (user, meta) => holder.get().sessionCreator(user, meta),
|
|
37
|
+
sessionRevoker: (sid) => holder.get().sessionRevoker(sid),
|
|
38
|
+
sessionChecker: (sid, userId) => holder.get().sessionChecker(sid, userId),
|
|
39
|
+
}),
|
|
40
|
+
asMassRevoker: () => (userId) => holder.get().sessionMassRevoker(userId),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// feature.ts contract tests for subscription-mollie.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "../constants";
|
|
5
|
+
import { createSubscriptionMollieFeature } from "../feature";
|
|
6
|
+
|
|
7
|
+
const VALID_OPTIONS = {
|
|
8
|
+
apiKey: "test_dummy_apikey",
|
|
9
|
+
webhookUrl: "https://app.example.com/api/subscription/webhook/mollie",
|
|
10
|
+
priceToTier: { plan_pro: "pro" },
|
|
11
|
+
priceToConfig: {
|
|
12
|
+
plan_pro: {
|
|
13
|
+
amountValue: "9.99",
|
|
14
|
+
amountCurrency: "EUR",
|
|
15
|
+
interval: "1 month",
|
|
16
|
+
description: "Pro Plan",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("createSubscriptionMollieFeature — shape", () => {
|
|
22
|
+
test("has the expected name", () => {
|
|
23
|
+
const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
|
|
24
|
+
expect(feature.name).toBe(SUBSCRIPTION_MOLLIE_FEATURE);
|
|
25
|
+
expect(feature.name).toBe("subscription-mollie");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("requires only subscription-foundation (alles app-wide via factory-options)", () => {
|
|
29
|
+
const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
|
|
30
|
+
expect(feature.requires).toContain("billing-foundation");
|
|
31
|
+
expect(feature.requires).not.toContain("config");
|
|
32
|
+
expect(feature.requires).not.toContain("secrets");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("createSubscriptionMollieFeature — module-load validation", () => {
|
|
37
|
+
test("throws bei empty apiKey", () => {
|
|
38
|
+
expect(() => createSubscriptionMollieFeature({ ...VALID_OPTIONS, apiKey: "" })).toThrow(
|
|
39
|
+
/apiKey is empty/,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("throws bei empty webhookUrl", () => {
|
|
44
|
+
expect(() => createSubscriptionMollieFeature({ ...VALID_OPTIONS, webhookUrl: "" })).toThrow(
|
|
45
|
+
/webhookUrl is empty/,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("throws bei priceToTier ↔ priceToConfig drift (priceId nur in tier)", () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
createSubscriptionMollieFeature({
|
|
52
|
+
...VALID_OPTIONS,
|
|
53
|
+
priceToTier: { plan_pro: "pro", plan_business: "business" },
|
|
54
|
+
// plan_business fehlt in config
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/missing config:.*plan_business/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("throws bei priceToTier ↔ priceToConfig drift (priceId nur in config)", () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
createSubscriptionMollieFeature({
|
|
62
|
+
...VALID_OPTIONS,
|
|
63
|
+
priceToConfig: {
|
|
64
|
+
...VALID_OPTIONS.priceToConfig,
|
|
65
|
+
plan_extra: {
|
|
66
|
+
amountValue: "29.99",
|
|
67
|
+
amountCurrency: "EUR",
|
|
68
|
+
interval: "1 month",
|
|
69
|
+
description: "Extra",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
// plan_extra fehlt in tier
|
|
73
|
+
}),
|
|
74
|
+
).toThrow(/missing tier:.*plan_extra/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("subscription-mollie — plugin-registration", () => {
|
|
79
|
+
test("registers under entityName 'mollie' for subscription-foundation extension", () => {
|
|
80
|
+
const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
|
|
81
|
+
expect(
|
|
82
|
+
feature.extensionUsages.some(
|
|
83
|
+
(u) => u.extensionName === "subscriptionProvider" && u.entityName === MOLLIE_PROVIDER_NAME,
|
|
84
|
+
),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("plugin has verifyAndParseWebhook + createCheckoutSession; KEIN portal/cancel (Mollie-Limit)", () => {
|
|
89
|
+
// Drift-Pin: Mollie's Plugin-shape ist intentional schmaler als Stripe.
|
|
90
|
+
// Wenn jemand einen createPortalSession-stub hinzufügt der "not-supported"
|
|
91
|
+
// wirft, würde das die foundation-error-Story ändern. Phase-5.3-MVP
|
|
92
|
+
// lässt die optional-Fields KOMPLETT weg.
|
|
93
|
+
const feature = createSubscriptionMollieFeature(VALID_OPTIONS);
|
|
94
|
+
const usage = feature.extensionUsages.find((u) => u.entityName === MOLLIE_PROVIDER_NAME);
|
|
95
|
+
const plugin = usage?.options as {
|
|
96
|
+
verifyAndParseWebhook?: unknown;
|
|
97
|
+
createCheckoutSession?: unknown;
|
|
98
|
+
createPortalSession?: unknown;
|
|
99
|
+
cancelSubscription?: unknown;
|
|
100
|
+
};
|
|
101
|
+
expect(typeof plugin?.verifyAndParseWebhook).toBe("function");
|
|
102
|
+
expect(typeof plugin?.createCheckoutSession).toBe("function");
|
|
103
|
+
expect(plugin?.createPortalSession).toBeUndefined();
|
|
104
|
+
expect(plugin?.cancelSubscription).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|