@cosmicdrift/kumiko-bundled-features 0.14.0 → 0.16.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 +2 -2
- package/src/__tests__/env-schemas.test.ts +1 -1
- package/src/__tests__/es-ops-e2e.integration.ts +10 -9
- package/src/audit/__tests__/audit.integration.ts +3 -3
- package/src/audit/handlers/list.query.ts +39 -51
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +4 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +4 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +5 -4
- package/src/auth-email-password/__tests__/auth.integration.ts +4 -3
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +1 -1
- package/src/auth-email-password/__tests__/email-templates.test.ts +1 -1
- package/src/auth-email-password/__tests__/email-verification.integration.ts +7 -10
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +1 -1
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +4 -3
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +16 -43
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +6 -9
- package/src/auth-email-password/__tests__/password-reset.integration.ts +8 -7
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +4 -3
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +19 -32
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +6 -5
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +1 -1
- package/src/auth-email-password/__tests__/signed-token.test.ts +1 -1
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +11 -15
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +26 -26
- package/src/auth-email-password/handlers/invite-accept.write.ts +24 -21
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -8
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +20 -17
- package/src/auth-email-password/handlers/signup-confirm.write.ts +3 -7
- package/src/auth-email-password/seeding.ts +1 -1
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +1 -2
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +10 -19
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +12 -18
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +12 -17
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +1 -1
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +1 -8
- package/src/auth-email-password/web/__tests__/test-utils.tsx +4 -8
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +2 -8
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +10 -15
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +1 -1
- package/src/billing-foundation/__tests__/feature.test.ts +1 -1
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +6 -5
- package/src/billing-foundation/db/queries/subscription-projection.ts +15 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +2 -6
- package/src/billing-foundation/handlers/create-portal-session.write.ts +2 -2
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +4 -1
- package/src/billing-foundation/projection.ts +32 -13
- package/src/cap-counter/__tests__/cap-counter.integration.ts +1 -1
- package/src/cap-counter/__tests__/enforce-cap.test.ts +37 -32
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +14 -20
- package/src/cap-counter/handlers/get-counter.query.ts +7 -13
- package/src/cap-counter/handlers/increment.write.ts +2 -2
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-in-app/handlers/inbox.query.ts +7 -13
- package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -9
- package/src/channel-in-app/handlers/mark-read.write.ts +8 -14
- package/src/channel-in-app/handlers/unread-count.query.ts +10 -9
- package/src/channel-in-app/in-app-channel.ts +10 -12
- package/src/channel-in-app/tables.ts +1 -1
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +1 -1
- package/src/compliance-profiles/__tests__/seeding.integration.ts +1 -1
- package/src/compliance-profiles/_internal/parse-override.ts +19 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +10 -32
- package/src/compliance-profiles/handlers/needs-profile.query.ts +4 -7
- package/src/compliance-profiles/handlers/set-profile.write.ts +5 -7
- package/src/compliance-profiles/resolve-for-tenant.ts +11 -27
- package/src/compliance-profiles/schema/profile-selection.ts +2 -2
- package/src/compliance-profiles/seeding.ts +4 -7
- package/src/config/__tests__/app-overrides.test.ts +1 -1
- package/src/config/__tests__/cascade.integration.ts +1 -1
- package/src/config/__tests__/config.integration.ts +8 -27
- package/src/config/db/queries/resolver.ts +47 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +1 -1
- package/src/config/resolver.ts +14 -62
- package/src/config/table.ts +4 -4
- package/src/config/write-helpers.ts +7 -11
- package/src/custom-fields/__tests__/audit-integration.integration.ts +6 -6
- package/src/custom-fields/__tests__/custom-fields.integration.ts +7 -7
- package/src/custom-fields/__tests__/feature.test.ts +1 -1
- package/src/custom-fields/__tests__/field-access.integration.ts +6 -6
- package/src/custom-fields/__tests__/quota.integration.ts +6 -6
- package/src/custom-fields/__tests__/retention.integration.ts +12 -10
- package/src/custom-fields/__tests__/user-data-rights.integration.ts +27 -17
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +5 -5
- package/src/custom-fields/db/queries/field-access.ts +16 -0
- package/src/custom-fields/db/queries/projection.ts +43 -0
- package/src/custom-fields/db/queries/quota.ts +14 -0
- package/src/custom-fields/db/queries/retention.ts +39 -0
- package/src/custom-fields/db/queries/user-data-rights.ts +54 -0
- package/src/custom-fields/lib/field-access.ts +2 -41
- package/src/custom-fields/lib/quota.ts +2 -25
- package/src/custom-fields/run-retention.ts +19 -21
- package/src/custom-fields/wire-for-entity.ts +30 -23
- package/src/custom-fields/wire-user-data-rights.ts +33 -85
- package/src/data-retention/__tests__/data-retention.integration.ts +1 -1
- package/src/data-retention/__tests__/keep-for.test.ts +1 -1
- package/src/data-retention/__tests__/override-schema.test.ts +1 -1
- package/src/data-retention/__tests__/policy-for.integration.ts +1 -1
- package/src/data-retention/__tests__/resolver.test.ts +1 -1
- package/src/data-retention/handlers/policy-for.query.ts +5 -8
- package/src/data-retention/resolve-for-tenant.ts +6 -8
- package/src/data-retention/schema/tenant-retention-override.ts +2 -2
- package/src/delivery/__tests__/delivery-events.integration.ts +8 -21
- package/src/delivery/__tests__/delivery.integration.ts +100 -190
- package/src/delivery/db/queries/preferences.ts +30 -0
- package/src/delivery/delivery-service.ts +8 -36
- package/src/delivery/feature.ts +10 -2
- package/src/delivery/handlers/log.query.ts +5 -7
- package/src/delivery/handlers/preferences.query.ts +2 -5
- package/src/delivery/tables.ts +26 -1
- package/src/delivery/upsert-preference.ts +8 -14
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +30 -30
- package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -6
- package/src/feature-toggles/db/queries/toggle-state.ts +25 -0
- package/src/feature-toggles/feature.ts +16 -2
- package/src/feature-toggles/global-feature-state-table.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +9 -2
- package/src/feature-toggles/handlers/registered.query.ts +3 -7
- package/src/feature-toggles/handlers/set.write.ts +37 -25
- package/src/feature-toggles/toggle-runtime.ts +3 -6
- package/src/file-foundation/__tests__/feature.test.ts +1 -1
- package/src/file-foundation/__tests__/file-foundation.integration.ts +1 -1
- package/src/file-provider-inmemory/__tests__/feature.test.ts +1 -1
- package/src/file-provider-s3/__tests__/feature.test.ts +1 -1
- package/src/files/__tests__/files.integration.ts +18 -7
- package/src/files/schema/file-ref.ts +1 -1
- package/src/files-provider-s3/__tests__/env-helper.test.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +1 -1
- package/src/jobs/__tests__/job-system-user.integration.ts +1 -1
- package/src/jobs/__tests__/jobs-events.integration.ts +8 -21
- package/src/jobs/__tests__/jobs-feature.integration.ts +1 -1
- package/src/jobs/feature.ts +26 -15
- package/src/jobs/handlers/detail.query.ts +10 -8
- package/src/jobs/handlers/list.query.ts +9 -21
- package/src/jobs/handlers/retry.write.ts +2 -7
- package/src/jobs/job-run-logger.ts +3 -9
- package/src/jobs/job-run-table.ts +49 -17
- package/src/legal-pages/__tests__/legal-pages.integration.ts +1 -1
- package/src/mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +1 -1
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +1 -1
- package/src/mail-transport-smtp/__tests__/feature.test.ts +1 -1
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +1 -1
- package/src/renderer-foundation/__tests__/api.test.ts +2 -2
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +1 -1
- package/src/renderer-simple/__tests__/adapter.test.ts +2 -2
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +6 -5
- package/src/secrets/__tests__/rotate.integration.ts +6 -9
- package/src/secrets/__tests__/secrets-events.integration.ts +6 -12
- package/src/secrets/__tests__/secrets.integration.ts +6 -11
- package/src/secrets/db/queries/read.ts +16 -0
- package/src/secrets/handlers/list.query.ts +16 -17
- package/src/secrets/handlers/rotate.job.ts +8 -12
- package/src/secrets/secrets-context.ts +9 -21
- package/src/secrets/table.ts +1 -1
- package/src/sessions/__tests__/cleanup.integration.ts +8 -6
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +7 -6
- package/src/sessions/__tests__/sessions.integration.ts +23 -38
- package/src/sessions/__tests__/test-helpers.ts +1 -1
- package/src/sessions/db/queries/cleanup.ts +21 -0
- package/src/sessions/handlers/cleanup.job.ts +6 -29
- package/src/sessions/handlers/list.query.ts +24 -24
- package/src/sessions/handlers/mine.query.ts +24 -23
- package/src/sessions/handlers/revoke-all-for-user.write.ts +7 -11
- package/src/sessions/handlers/revoke-all-others.write.ts +7 -12
- package/src/sessions/handlers/revoke.write.ts +11 -18
- package/src/sessions/schema/user-session.ts +2 -2
- package/src/sessions/session-callbacks.ts +19 -21
- package/src/subscription-mollie/__tests__/feature.test.ts +1 -1
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +1 -1
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +8 -7
- package/src/subscription-stripe/__tests__/feature.test.ts +1 -1
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +14 -15
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +1 -1
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +14 -14
- package/src/subscription-stripe/verify-webhook.ts +1 -1
- package/src/template-resolver/__tests__/handlers.integration.ts +1 -1
- package/src/template-resolver/__tests__/template-resolver.integration.ts +3 -2
- package/src/template-resolver/api.ts +7 -13
- package/src/template-resolver/handlers/archive.write.ts +4 -7
- package/src/template-resolver/handlers/find-by-id.query.ts +4 -7
- package/src/template-resolver/handlers/list.query.ts +13 -21
- package/src/template-resolver/handlers/publish.write.ts +4 -7
- package/src/template-resolver/handlers/upsert-system.write.ts +7 -10
- package/src/template-resolver/handlers/upsert-tenant.write.ts +7 -10
- package/src/template-resolver/table.ts +2 -5
- package/src/tenant/__tests__/multi-tenant.integration.ts +1 -1
- package/src/tenant/__tests__/seed-testing.integration.ts +19 -45
- package/src/tenant/__tests__/tenant.integration.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +3 -8
- package/src/tenant/handlers/add-member.write.ts +6 -8
- package/src/tenant/handlers/cancel-invitation.write.ts +5 -7
- package/src/tenant/handlers/invitations.query.ts +5 -10
- package/src/tenant/handlers/me.query.ts +2 -3
- package/src/tenant/handlers/members.query.ts +4 -5
- package/src/tenant/handlers/memberships.query.ts +2 -5
- package/src/tenant/handlers/remove-member.write.ts +6 -8
- package/src/tenant/handlers/resolve-user-ids.query.ts +6 -16
- package/src/tenant/handlers/update-member-roles.write.ts +6 -8
- package/src/tenant/invitation-table.ts +2 -5
- package/src/tenant/membership-table.ts +3 -6
- package/src/tenant/schema/tenant.ts +2 -2
- package/src/tenant/seeding.ts +12 -18
- package/src/text-content/README.md +1 -1
- package/src/text-content/__tests__/text-content.integration.ts +2 -2
- package/src/text-content/api.ts +2 -9
- package/src/text-content/handlers/by-slug.query.ts +6 -9
- package/src/text-content/handlers/by-tenant.query.ts +2 -2
- package/src/text-content/handlers/set.write.ts +7 -9
- package/src/text-content/seeding.ts +6 -9
- package/src/text-content/table.ts +2 -2
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +31 -45
- package/src/text-content/web/__tests__/group-blocks.test.ts +1 -18
- package/src/text-content/web/client-plugin.tsx +11 -23
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +10 -16
- package/src/tier-engine/__tests__/compose-app.test.ts +1 -1
- package/src/tier-engine/__tests__/drift.test.ts +1 -1
- package/src/tier-engine/__tests__/resolver.integration.ts +6 -6
- package/src/tier-engine/__tests__/tier-engine.integration.ts +1 -1
- package/src/tier-engine/feature.ts +9 -16
- package/src/user/__tests__/seed-testing.integration.ts +10 -22
- package/src/user/__tests__/user-status.test.ts +1 -1
- package/src/user/__tests__/user.integration.ts +6 -5
- package/src/user/handlers/create.write.ts +5 -7
- package/src/user/handlers/find-for-auth.query.ts +5 -7
- package/src/user/schema/user.ts +2 -2
- package/src/user/seeding.ts +2 -3
- package/src/user-data-rights/__tests__/audit-log.integration.ts +24 -12
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +64 -37
- package/src/user-data-rights/__tests__/download.integration.ts +29 -46
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +35 -28
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +2 -2
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +1 -1
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +11 -15
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +10 -12
- package/src/user-data-rights/__tests__/request-export.integration.ts +23 -16
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +24 -32
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +142 -137
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +46 -28
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +20 -14
- package/src/user-data-rights/__tests__/token-helpers.test.ts +1 -1
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +1 -1
- package/src/user-data-rights/__tests__/zip-path.test.ts +1 -1
- package/src/user-data-rights/audit-download.ts +3 -3
- package/src/user-data-rights/db/queries/export-jobs.ts +23 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +13 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +28 -22
- package/src/user-data-rights/handlers/download-by-job.query.ts +11 -21
- package/src/user-data-rights/handlers/download-by-token.query.ts +20 -35
- package/src/user-data-rights/handlers/export-status.query.ts +19 -33
- package/src/user-data-rights/handlers/lift-restriction.write.ts +7 -12
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +14 -23
- package/src/user-data-rights/handlers/my-audit-log.query.ts +33 -23
- package/src/user-data-rights/handlers/request-deletion.write.ts +15 -15
- package/src/user-data-rights/handlers/request-export.write.ts +7 -11
- package/src/user-data-rights/handlers/restrict-account.write.ts +12 -12
- package/src/user-data-rights/run-export-jobs.ts +20 -60
- package/src/user-data-rights/run-forget-cleanup.ts +19 -33
- package/src/user-data-rights/run-user-export.ts +4 -6
- package/src/user-data-rights/schema/download-attempt.ts +2 -2
- package/src/user-data-rights/schema/download-token.ts +2 -2
- package/src/user-data-rights/schema/export-job.ts +2 -3
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +37 -30
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +17 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +12 -27
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +16 -18
- package/CHANGELOG.md +0 -689
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// pins both paths so a silent rename breaks here, not in a compliance
|
|
6
6
|
// audit query.
|
|
7
7
|
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
9
|
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
11
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
12
|
import {
|
|
11
13
|
createEnvMasterKeyProvider,
|
|
@@ -17,8 +19,6 @@ import {
|
|
|
17
19
|
type TestStack,
|
|
18
20
|
unsafePushTables,
|
|
19
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
-
import { eq } from "drizzle-orm";
|
|
21
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
22
22
|
import { createSecretsFeature } from "../feature";
|
|
23
23
|
import {
|
|
24
24
|
createSecretsContext,
|
|
@@ -59,8 +59,8 @@ afterAll(async () => {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
beforeEach(async () => {
|
|
62
|
-
await stack.db.
|
|
63
|
-
await stack.db.
|
|
62
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${eventsTable.tableName}"`);
|
|
63
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantSecretsTable.tableName}"`);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
describe("tenantSecret lifecycle events", () => {
|
|
@@ -71,10 +71,7 @@ describe("tenantSecret lifecycle events", () => {
|
|
|
71
71
|
admin,
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
const created = await stack.db
|
|
75
|
-
.select()
|
|
76
|
-
.from(eventsTable)
|
|
77
|
-
.where(eq(eventsTable.type, "tenant-secret.created"));
|
|
74
|
+
const created = await selectMany(stack.db, eventsTable, { type: "tenant-secret.created" });
|
|
78
75
|
expect(created.length).toBe(1);
|
|
79
76
|
// aggregateType stable; downstream MSPs filter by this.
|
|
80
77
|
expect(created[0]?.aggregateType).toBe("tenant-secret");
|
|
@@ -91,10 +88,7 @@ describe("tenantSecret lifecycle events", () => {
|
|
|
91
88
|
);
|
|
92
89
|
await stack.http.writeOk("secrets:write:delete", { key: "example.to.delete" }, admin);
|
|
93
90
|
|
|
94
|
-
const events = await stack.db
|
|
95
|
-
.select()
|
|
96
|
-
.from(eventsTable)
|
|
97
|
-
.where(eq(eventsTable.aggregateType, "tenant-secret"));
|
|
91
|
+
const events = await selectMany(stack.db, eventsTable, { aggregateType: "tenant-secret" });
|
|
98
92
|
|
|
99
93
|
// Exactly 2 events on the same aggregate-stream: created + deleted.
|
|
100
94
|
expect(events.length).toBe(2);
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// (samples/secrets-demo) shows the broader rotation + cross-feature flow;
|
|
5
5
|
// this test covers just the feature's own handlers.
|
|
6
6
|
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
10
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
11
|
import {
|
|
10
12
|
createEnvMasterKeyProvider,
|
|
@@ -16,8 +18,6 @@ import {
|
|
|
16
18
|
type TestStack,
|
|
17
19
|
unsafePushTables,
|
|
18
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
-
import { and, eq } from "drizzle-orm";
|
|
20
|
-
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
21
|
import { createSecretsFeature } from "../feature";
|
|
22
22
|
import { createSecretsContext } from "../secrets-context";
|
|
23
23
|
import { type StoredEnvelope, tenantSecretsTable } from "../table";
|
|
@@ -76,15 +76,10 @@ describe("secrets feature — CRUD round-trip", () => {
|
|
|
76
76
|
expect(row?.kekVersion).toBe(1);
|
|
77
77
|
|
|
78
78
|
// DB row holds an envelope, no plaintext
|
|
79
|
-
const [dbRow] = await stack.db
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
and(
|
|
84
|
-
eq(tenantSecretsTable.tenantId, admin.tenantId),
|
|
85
|
-
eq(tenantSecretsTable.key, "api.key.x"),
|
|
86
|
-
),
|
|
87
|
-
);
|
|
79
|
+
const [dbRow] = await selectMany(stack.db, tenantSecretsTable, {
|
|
80
|
+
tenantId: admin.tenantId,
|
|
81
|
+
key: "api.key.x",
|
|
82
|
+
});
|
|
88
83
|
if (!dbRow) throw new Error("row missing");
|
|
89
84
|
const env = dbRow.envelope as StoredEnvelope;
|
|
90
85
|
expect(env.ciphertext).toBeTruthy();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { StoredEnvelope } from "../../table";
|
|
5
|
+
|
|
6
|
+
export async function selectTenantSecretEnvelope(
|
|
7
|
+
db: DbRunner,
|
|
8
|
+
tenantId: TenantId,
|
|
9
|
+
key: string,
|
|
10
|
+
): Promise<StoredEnvelope | undefined> {
|
|
11
|
+
const rows = await asRawClient(db).unsafe<{ envelope: StoredEnvelope }>(
|
|
12
|
+
`SELECT envelope FROM read_tenant_secrets WHERE tenant_id = $1 AND key = $2 LIMIT 1`,
|
|
13
|
+
[tenantId, key],
|
|
14
|
+
);
|
|
15
|
+
return rows[0]?.envelope;
|
|
16
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { eq } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { tenantSecretsTable } from "../table";
|
|
5
5
|
|
|
@@ -11,28 +11,27 @@ export const listQuery = defineQueryHandler({
|
|
|
11
11
|
schema: z.object({}),
|
|
12
12
|
access: { roles: ["TenantAdmin"] },
|
|
13
13
|
handler: async (event, ctx) => {
|
|
14
|
-
const rows = await
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
const rows = await selectMany<{
|
|
15
|
+
key: string;
|
|
16
|
+
kekVersion: number;
|
|
17
|
+
metadata: { redactedPreview?: string; hint?: string };
|
|
18
|
+
lastRotatedAt: unknown;
|
|
19
|
+
insertedAt: unknown;
|
|
20
|
+
}>(
|
|
21
|
+
ctx.db.raw,
|
|
22
|
+
tenantSecretsTable,
|
|
23
|
+
{ tenantId: event.user.tenantId },
|
|
24
|
+
{
|
|
25
|
+
orderBy: { col: "key", direction: "asc" },
|
|
26
|
+
},
|
|
27
|
+
);
|
|
29
28
|
return rows.map((r) => ({
|
|
30
29
|
key: r.key,
|
|
31
30
|
redactedPreview: r.metadata.redactedPreview ?? null,
|
|
32
31
|
hint: r.metadata.hint ?? null,
|
|
33
32
|
kekVersion: r.kekVersion,
|
|
34
33
|
lastRotatedAt: r.lastRotatedAt,
|
|
35
|
-
createdAt: r.
|
|
34
|
+
createdAt: r.insertedAt,
|
|
36
35
|
}));
|
|
37
36
|
},
|
|
38
37
|
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// that landed the row on the new kekVersion first surfaces here as a
|
|
18
18
|
// version_conflict error (counted as "skipped", not "failed").
|
|
19
19
|
|
|
20
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
21
|
import {
|
|
21
22
|
createEventStoreExecutor,
|
|
22
23
|
createTenantDb,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
27
28
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
28
29
|
import { rewrapDek } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
-
import { ne } from "drizzle-orm";
|
|
30
30
|
import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table";
|
|
31
31
|
|
|
32
32
|
const DEFAULT_BATCH_SIZE = 100;
|
|
@@ -100,17 +100,13 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const targetVersion = provider.currentVersion();
|
|
103
|
-
const batch = await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
})
|
|
111
|
-
.from(tenantSecretsTable)
|
|
112
|
-
.where(ne(tenantSecretsTable.kekVersion, targetVersion))
|
|
113
|
-
.limit(batchSize);
|
|
103
|
+
const batch = await selectMany<{
|
|
104
|
+
id: string;
|
|
105
|
+
tenantId: string;
|
|
106
|
+
version: number;
|
|
107
|
+
envelope: StoredEnvelope;
|
|
108
|
+
kekVersion: number;
|
|
109
|
+
}>(db, tenantSecretsTable, { kekVersion: { ne: targetVersion } }, { limit: batchSize });
|
|
114
110
|
|
|
115
111
|
if (batch.length === 0) break;
|
|
116
112
|
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
// read logged") now sits on the events-table instead of a
|
|
13
13
|
// dedicated audit-table.
|
|
14
14
|
|
|
15
|
+
import { fetchOne, transaction } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
16
|
import {
|
|
16
17
|
createEventStoreExecutor,
|
|
17
18
|
createTenantDb,
|
|
18
19
|
type DbConnection,
|
|
19
|
-
fetchOne,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
21
21
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
22
|
import { InternalError, type WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -31,8 +31,8 @@ import {
|
|
|
31
31
|
type SecretsContext,
|
|
32
32
|
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
33
33
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
34
|
-
import { and, eq } from "drizzle-orm";
|
|
35
34
|
import { z } from "zod";
|
|
35
|
+
import { selectTenantSecretEnvelope } from "./db/queries/read";
|
|
36
36
|
import {
|
|
37
37
|
type StoredEnvelope,
|
|
38
38
|
type StoredMetadata,
|
|
@@ -118,12 +118,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
async function lookup(tenantId: string, key: string): Promise<SecretLookupRow | undefined> {
|
|
121
|
-
return fetchOne<SecretLookupRow>(
|
|
122
|
-
db,
|
|
123
|
-
tenantSecretsTable,
|
|
124
|
-
eq(tenantSecretsTable.tenantId, tenantId),
|
|
125
|
-
eq(tenantSecretsTable.key, key),
|
|
126
|
-
);
|
|
121
|
+
return fetchOne<SecretLookupRow>(db, tenantSecretsTable, { tenantId, key });
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
return {
|
|
@@ -142,18 +137,11 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
142
137
|
return createSecret(plaintext);
|
|
143
138
|
}
|
|
144
139
|
|
|
145
|
-
const plaintext = await
|
|
146
|
-
// Inline select inside the TX — fetchOne's
|
|
147
|
-
// doesn't widen to
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const [row] = await tx
|
|
151
|
-
.select({ envelope: tenantSecretsTable.envelope })
|
|
152
|
-
.from(tenantSecretsTable)
|
|
153
|
-
.where(and(eq(tenantSecretsTable.tenantId, tenantId), eq(tenantSecretsTable.key, key)))
|
|
154
|
-
.limit(1);
|
|
155
|
-
if (!row) return undefined;
|
|
156
|
-
const envelope = row.envelope;
|
|
140
|
+
const plaintext = await transaction(db, async (tx) => {
|
|
141
|
+
// Inline select inside the TX via raw client — fetchOne's connection
|
|
142
|
+
// type doesn't widen to the transaction object cleanly.
|
|
143
|
+
const envelope = await selectTenantSecretEnvelope(tx, tenantId, key);
|
|
144
|
+
if (!envelope) return undefined;
|
|
157
145
|
const pt = await decryptValue(decodeEnvelope(envelope), provider);
|
|
158
146
|
|
|
159
147
|
// One event per read on its own aggregate-stream (fresh UUID as
|
|
@@ -173,7 +161,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
173
161
|
userId: auditCtx.userId,
|
|
174
162
|
handlerName: auditCtx.handlerName,
|
|
175
163
|
});
|
|
176
|
-
await append(tx, {
|
|
164
|
+
await append(tx as unknown as Parameters<typeof append>[0], {
|
|
177
165
|
aggregateId: readId,
|
|
178
166
|
aggregateType: "tenantSecretRead",
|
|
179
167
|
tenantId,
|
package/src/secrets/table.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
instant,
|
|
4
4
|
integer,
|
|
5
5
|
jsonb,
|
|
6
|
+
sql,
|
|
6
7
|
table,
|
|
7
8
|
text,
|
|
8
9
|
uniqueIndex,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
createNumberField,
|
|
13
14
|
createTextField,
|
|
14
15
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
-
import { sql } from "drizzle-orm";
|
|
16
16
|
|
|
17
17
|
// Envelope stored as a single jsonb blob. All ops are upsert-by-(tenantId, key)
|
|
18
18
|
// so there's no value in decomposing the envelope into separate columns —
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// exercised by the framework's job tests. Here we pin the semantics: old
|
|
5
5
|
// expired/revoked rows go, live rows stay, batching + signal work.
|
|
6
6
|
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
7
10
|
import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
11
|
import {
|
|
9
12
|
setupTestStack,
|
|
@@ -11,8 +14,7 @@ import {
|
|
|
11
14
|
testTenantId,
|
|
12
15
|
unsafeCreateEntityTable,
|
|
13
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
-
import {
|
|
15
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
17
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
16
18
|
import { createSessionsFeature } from "../feature";
|
|
17
19
|
import { cleanupJob } from "../handlers/cleanup.job";
|
|
18
20
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
@@ -46,7 +48,7 @@ afterAll(async () => {
|
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
beforeEach(async () => {
|
|
49
|
-
await stack.db
|
|
51
|
+
await resetTestTables(stack.db, [userSessionTable]);
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
type JobCtx = Pick<AppContext, "db" | "registry" | "log">;
|
|
@@ -74,7 +76,7 @@ async function seedSession(opts: {
|
|
|
74
76
|
const past = sql`now() - ${sql.raw(`interval '${opts.ageDays} days'`)}`;
|
|
75
77
|
const future = sql`now() + ${sql.raw(`interval '30 days'`)}`;
|
|
76
78
|
|
|
77
|
-
await stack.db
|
|
79
|
+
await insertOne(stack.db, userSessionTable, {
|
|
78
80
|
id: opts.id,
|
|
79
81
|
tenantId: TENANT,
|
|
80
82
|
userId: opts.userId,
|
|
@@ -88,7 +90,7 @@ async function seedSession(opts: {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
async function countSessions(): Promise<number> {
|
|
91
|
-
const rows = await stack.db
|
|
93
|
+
const rows = await selectMany(stack.db, userSessionTable);
|
|
92
94
|
return rows.length;
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -111,7 +113,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
|
|
|
111
113
|
await cleanupJob({}, jobCtx());
|
|
112
114
|
|
|
113
115
|
expect(await countSessions()).toBe(1);
|
|
114
|
-
const [remaining] = await stack.db
|
|
116
|
+
const [remaining] = await selectMany(stack.db, userSessionTable);
|
|
115
117
|
expect(remaining?.["revokedAt"]).toBeNull();
|
|
116
118
|
});
|
|
117
119
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
1
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
3
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
6
|
import {
|
|
@@ -9,7 +11,6 @@ import {
|
|
|
9
11
|
unsafePushTables,
|
|
10
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
13
|
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
14
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
14
15
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -38,7 +39,7 @@ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
|
38
39
|
|
|
39
40
|
// vi.fn spy for the revoker — lets us assert exact call counts and arguments
|
|
40
41
|
// per test without leaking module-level mutable state across suites.
|
|
41
|
-
const massRevokeSpy =
|
|
42
|
+
const massRevokeSpy = mock<(userId: string) => Promise<number>>();
|
|
42
43
|
|
|
43
44
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
44
45
|
|
|
@@ -88,9 +89,9 @@ afterAll(async () => {
|
|
|
88
89
|
});
|
|
89
90
|
|
|
90
91
|
beforeEach(async () => {
|
|
91
|
-
await stack.db.
|
|
92
|
-
await stack.db.
|
|
93
|
-
await stack.db.
|
|
92
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
93
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
94
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
|
|
94
95
|
massRevokeSpy.mockClear();
|
|
95
96
|
});
|
|
96
97
|
|
|
@@ -143,7 +144,7 @@ describe("password change mass-revokes every live session", () => {
|
|
|
143
144
|
).toBe(401);
|
|
144
145
|
|
|
145
146
|
// DB state confirms: zero live rows for this user
|
|
146
|
-
const liveRows = await stack.db
|
|
147
|
+
const liveRows = await selectMany(stack.db, userSessionTable);
|
|
147
148
|
const stillLive = liveRows.filter((r) => r["revokedAt"] === null);
|
|
148
149
|
expect(stillLive).toHaveLength(0);
|
|
149
150
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
1
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
3
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
6
|
import {
|
|
@@ -8,10 +10,8 @@ import {
|
|
|
8
10
|
unsafeCreateEntityTable,
|
|
9
11
|
unsafePushTables,
|
|
10
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
|
-
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
-
import { and, eq } from "drizzle-orm";
|
|
13
|
+
import { createLateBoundHolder, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
14
|
import { Temporal } from "temporal-polyfill";
|
|
14
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
15
15
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
16
16
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
17
|
import { createConfigFeature } from "../../config";
|
|
@@ -75,9 +75,7 @@ afterAll(async () => {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
beforeEach(async () => {
|
|
78
|
-
await stack.db
|
|
79
|
-
await stack.db.delete(tenantMembershipsTable);
|
|
80
|
-
await stack.db.delete(userSessionTable);
|
|
78
|
+
await resetTestTables(stack.db, [userTable, tenantMembershipsTable, userSessionTable]);
|
|
81
79
|
});
|
|
82
80
|
|
|
83
81
|
describe("sessions feature — login → check → revoke → rejected", () => {
|
|
@@ -85,7 +83,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
85
83
|
await h.seedUser("persist@example.com", "pw-long-enough");
|
|
86
84
|
const { sid } = await h.login("persist@example.com", "pw-long-enough");
|
|
87
85
|
|
|
88
|
-
const rows = await stack.db
|
|
86
|
+
const rows = await selectMany(stack.db, userSessionTable);
|
|
89
87
|
expect(rows).toHaveLength(1);
|
|
90
88
|
expect(rows[0]?.["id"]).toBe(sid);
|
|
91
89
|
expect(rows[0]?.["revokedAt"]).toBeNull();
|
|
@@ -126,7 +124,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
126
124
|
const logoutRes = await h.authedPost("/api/auth/logout", token);
|
|
127
125
|
expect(logoutRes.status).toBe(200);
|
|
128
126
|
|
|
129
|
-
const rows = await stack.db
|
|
127
|
+
const rows = await selectMany(stack.db, userSessionTable);
|
|
130
128
|
expect(rows[0]?.["id"]).toBe(sid);
|
|
131
129
|
expect(rows[0]?.["revokedAt"]).not.toBeNull();
|
|
132
130
|
|
|
@@ -153,10 +151,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
153
151
|
});
|
|
154
152
|
expect(firstRevoke.status).toBe(200);
|
|
155
153
|
|
|
156
|
-
const [rowAfterFirst] = await stack.db
|
|
157
|
-
.select()
|
|
158
|
-
.from(userSessionTable)
|
|
159
|
-
.where(eq(userSessionTable["id"], first.sid));
|
|
154
|
+
const [rowAfterFirst] = await selectMany(stack.db, userSessionTable, { id: first.sid });
|
|
160
155
|
const originalRevokedAt = rowAfterFirst?.["revokedAt"] as Temporal.Instant | null;
|
|
161
156
|
expect(originalRevokedAt).not.toBeNull();
|
|
162
157
|
|
|
@@ -173,10 +168,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
173
168
|
expect(body.error?.details?.reason).toBe("session_already_revoked");
|
|
174
169
|
|
|
175
170
|
// 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));
|
|
171
|
+
const [rowAfterRetry] = await selectMany(stack.db, userSessionTable, { id: first.sid });
|
|
180
172
|
const preservedRevokedAt = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
|
|
181
173
|
expect(preservedRevokedAt?.epochMilliseconds).toBe(originalRevokedAt?.epochMilliseconds);
|
|
182
174
|
});
|
|
@@ -340,10 +332,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
340
332
|
// not a bypass hack). If the audit-guard were missing, the second
|
|
341
333
|
// readout would move forward because one of the late racers would
|
|
342
334
|
// have overwritten t1.
|
|
343
|
-
const [row] = await stack.db
|
|
344
|
-
.select()
|
|
345
|
-
.from(userSessionTable)
|
|
346
|
-
.where(eq(userSessionTable["id"], sid));
|
|
335
|
+
const [row] = await selectMany(stack.db, userSessionTable, { id: sid });
|
|
347
336
|
const tAfterRace = row?.["revokedAt"] as Temporal.Instant | null;
|
|
348
337
|
expect(tAfterRace).not.toBeNull();
|
|
349
338
|
|
|
@@ -356,10 +345,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
356
345
|
});
|
|
357
346
|
expect(retry.status).toBe(422);
|
|
358
347
|
|
|
359
|
-
const [rowAfterRetry] = await stack.db
|
|
360
|
-
.select()
|
|
361
|
-
.from(userSessionTable)
|
|
362
|
-
.where(eq(userSessionTable["id"], sid));
|
|
348
|
+
const [rowAfterRetry] = await selectMany(stack.db, userSessionTable, { id: sid });
|
|
363
349
|
const tAfterRetry = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
|
|
364
350
|
expect(tAfterRetry?.epochMilliseconds).toBe(tAfterRace?.epochMilliseconds);
|
|
365
351
|
|
|
@@ -381,7 +367,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
381
367
|
|
|
382
368
|
// Hard-delete the session row so it's gone from the store (as opposed to
|
|
383
369
|
// soft-revoked). The JWT stays syntactically valid.
|
|
384
|
-
await stack.db
|
|
370
|
+
await deleteMany(stack.db, userSessionTable, { id: sid });
|
|
385
371
|
|
|
386
372
|
const res = await h.authedPost("/api/query", token, {
|
|
387
373
|
type: "user:query:user:me",
|
|
@@ -398,10 +384,12 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
398
384
|
|
|
399
385
|
// Back-date expiresAt so the row is still present + not revoked, just
|
|
400
386
|
// past its window. Simulates what a long-lived JWT would hit.
|
|
401
|
-
await
|
|
402
|
-
.
|
|
403
|
-
|
|
404
|
-
.
|
|
387
|
+
await updateMany(
|
|
388
|
+
stack.db,
|
|
389
|
+
userSessionTable,
|
|
390
|
+
{ expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") },
|
|
391
|
+
{ id: sid },
|
|
392
|
+
);
|
|
405
393
|
|
|
406
394
|
const res = await h.authedPost("/api/query", token, {
|
|
407
395
|
type: "user:query:user:me",
|
|
@@ -434,15 +422,12 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
434
422
|
// so she gets a fresh JWT with the new role in its claims. This is the
|
|
435
423
|
// actual production path — roles are tenant-membership data, not JWT
|
|
436
424
|
// metadata we can fiddle with directly.
|
|
437
|
-
await
|
|
438
|
-
.
|
|
439
|
-
|
|
440
|
-
.
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
eq(tenantMembershipsTable.tenantId, TENANT),
|
|
444
|
-
),
|
|
445
|
-
);
|
|
425
|
+
await updateMany(
|
|
426
|
+
stack.db,
|
|
427
|
+
tenantMembershipsTable,
|
|
428
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
429
|
+
{ userId: aliceId, tenantId: TENANT },
|
|
430
|
+
);
|
|
446
431
|
const aliceAsAdmin = await h.login("alice2@example.com", "pw-long-enough");
|
|
447
432
|
|
|
448
433
|
const asAdmin = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// const { token, sid } = await h.login("x@example.com", "pw");
|
|
9
9
|
// const res = await h.authedPost("/api/query", token, { type, payload });
|
|
10
10
|
|
|
11
|
+
import { expect } from "bun:test";
|
|
11
12
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
13
|
import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
13
14
|
import * as jose from "jose";
|
|
14
|
-
import { expect } from "vitest";
|
|
15
15
|
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
16
16
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
17
17
|
import { UserHandlers } from "../../user";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
|
|
4
|
+
export async function deleteStaleSessionsBatch(
|
|
5
|
+
db: DbConnection,
|
|
6
|
+
olderThanDays: number,
|
|
7
|
+
batchSize: number,
|
|
8
|
+
): Promise<number> {
|
|
9
|
+
const rows = (await asRawClient(db).unsafe(
|
|
10
|
+
`DELETE FROM "read_user_sessions"
|
|
11
|
+
WHERE "id" IN (
|
|
12
|
+
SELECT "id" FROM "read_user_sessions"
|
|
13
|
+
WHERE "expires_at" < now() - ($1::int * interval '1 day')
|
|
14
|
+
OR "revoked_at" < now() - ($1::int * interval '1 day')
|
|
15
|
+
LIMIT $2
|
|
16
|
+
)
|
|
17
|
+
RETURNING "id"`,
|
|
18
|
+
[olderThanDays, batchSize],
|
|
19
|
+
)) as readonly { id: string }[];
|
|
20
|
+
return rows.length;
|
|
21
|
+
}
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
21
21
|
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
22
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
23
|
-
import {
|
|
24
|
-
import { userSessionTable } from "../schema/user-session";
|
|
23
|
+
import { deleteStaleSessionsBatch } from "../db/queries/cleanup";
|
|
25
24
|
|
|
26
25
|
const DEFAULT_OLDER_THAN_DAYS = 30;
|
|
27
26
|
const DEFAULT_BATCH_SIZE = 1000;
|
|
@@ -45,10 +44,8 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
|
|
|
45
44
|
message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
|
|
46
45
|
});
|
|
47
46
|
}
|
|
48
|
-
const db = ctx.db as DbConnection;
|
|
47
|
+
const db = ctx.db as DbConnection;
|
|
49
48
|
|
|
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
49
|
const olderThanDaysRaw = payload.olderThanDays ?? DEFAULT_OLDER_THAN_DAYS;
|
|
53
50
|
const olderThanDays = Number(olderThanDaysRaw);
|
|
54
51
|
if (!Number.isFinite(olderThanDays) || olderThanDays < 0 || !Number.isInteger(olderThanDays)) {
|
|
@@ -61,8 +58,6 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
|
|
|
61
58
|
? Date.now() + payload.maxDurationMs
|
|
62
59
|
: Number.POSITIVE_INFINITY;
|
|
63
60
|
|
|
64
|
-
const cutoff = sql`now() - (${olderThanDays} * interval '1 day')`;
|
|
65
|
-
|
|
66
61
|
let deleted = 0;
|
|
67
62
|
let batchesProcessed = 0;
|
|
68
63
|
let stoppedReason: SessionCleanupResult["stoppedReason"] = "empty";
|
|
@@ -77,31 +72,13 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
|
|
|
77
72
|
break;
|
|
78
73
|
}
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
75
|
+
const batchDeleted = await deleteStaleSessionsBatch(db, olderThanDays, batchSize);
|
|
76
|
+
if (batchDeleted === 0) break;
|
|
100
77
|
|
|
101
|
-
deleted +=
|
|
78
|
+
deleted += batchDeleted;
|
|
102
79
|
batchesProcessed++;
|
|
103
80
|
|
|
104
|
-
if (
|
|
81
|
+
if (batchDeleted < batchSize) break;
|
|
105
82
|
}
|
|
106
83
|
|
|
107
84
|
const result: SessionCleanupResult = { deleted, batchesProcessed, stoppedReason };
|