@cosmicdrift/kumiko-bundled-features 0.333.0 → 0.335.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/dist/admin-shell/overview-panels.js +2 -2
- package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
- package/dist/agent-tools/agent-doc-lint.js +33 -3
- package/dist/agent-tools/agent-manifest.js +47 -6
- package/dist/agent-tools/filter-ops.d.ts +2 -0
- package/dist/agent-tools/filter-ops.js +4 -0
- package/dist/agent-tools/tool-catalog.js +1 -4
- package/dist/agent-tools/tool-dispatch.js +1 -4
- package/dist/audit/constants.d.ts +1 -1
- package/dist/audit/constants.js +1 -1
- package/dist/audit/handlers/list.query.js +4 -3
- package/dist/auth-email-password/handlers/change-password.write.js +2 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
- package/dist/auth-email-password/handlers/login.write.js +2 -0
- package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
- package/dist/auth-email-password/i18n.js +2 -0
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-gate.js +6 -8
- package/dist/auth-email-password/web/index.d.ts +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
- package/dist/auth-email-password/web/login-screen.js +1 -1
- package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
- package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
- package/dist/auth-email-password/web/session.d.ts +4 -0
- package/dist/auth-email-password/web/session.js +15 -0
- package/dist/auth-mfa/feature.js +3 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
- package/dist/auth-mfa/handlers/verify.write.js +2 -0
- package/dist/auth-mfa/hmac-token-codec.js +3 -0
- package/dist/auth-mfa/mfa-challenge-token.js +4 -0
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-client.js +4 -10
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/checkout-core.d.ts +5 -5
- package/dist/billing-foundation/checkout-core.js +10 -6
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/book-cap-usage.js +15 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-counter/feature.js +3 -2
- package/dist/cap-counter/i18n.d.ts +1 -0
- package/dist/cap-counter/i18n.js +6 -5
- package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
- package/dist/cap-overview/i18n.js +5 -0
- package/dist/cap-overview/web/cap-cards-panel.js +4 -1
- package/dist/cap-overview/web/cap-usage-bar.js +1 -1
- package/dist/channel-email/email-channel.js +12 -1
- package/dist/channel-in-app/tables.js +2 -6
- package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
- package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -2
- package/dist/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/table.d.ts +6 -0
- package/dist/config/web/i18n.js +13 -4
- package/dist/config/write-helpers.d.ts +1 -1
- package/dist/config/write-helpers.js +11 -5
- package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
- package/dist/custom-fields/constants.js +1 -4
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/types.js +7 -0
- package/dist/delivery/unsubscribe.js +5 -11
- package/dist/derivatives-sharp/render.js +27 -9
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/document-ingest-foundation/feature.js +5 -5
- package/dist/document-ingest-foundation/providers.d.ts +1 -0
- package/dist/document-ingest-foundation/providers.js +6 -0
- package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
- package/dist/document-ingest-foundation/write-document-extract.js +10 -0
- package/dist/feature-toggles/global-feature-state-table.js +2 -7
- package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
- package/dist/feature-toggles/toggle-sync-signal.js +3 -2
- package/dist/file-provider-s3/feature.js +1 -1
- package/dist/files-tenant-data/index.js +3 -2
- package/dist/folders/entity.d.ts +8 -0
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/form-draft/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +7 -8
- package/dist/form-draft/handlers/get.query.js +7 -1
- package/dist/foundation-shared/host-allowlist.d.ts +4 -0
- package/dist/foundation-shared/host-allowlist.js +14 -0
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
- package/dist/foundation-shared/mail-host-policy.js +3 -13
- package/dist/inbound-mail-foundation/connect-routes.js +7 -5
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
- package/dist/jobs/handlers/list.query.d.ts +7 -2
- package/dist/jobs/handlers/list.query.js +9 -2
- package/dist/jobs/handlers/retry.write.js +3 -2
- package/dist/jobs/handlers/tenant-failures.query.js +3 -9
- package/dist/jobs/job-run-logger.js +18 -2
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/tenant-job-failure-table.js +2 -6
- package/dist/ledger/entity.d.ts +16 -3
- package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
- package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
- package/dist/ledger/subject-reference.d.ts +4 -0
- package/dist/ledger/subject-reference.js +7 -0
- package/dist/legal-pages/feature.js +8 -1
- package/dist/managed-pages/handlers/branding.query.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/handlers/add-note.write.js +4 -9
- package/dist/notes-history-user-data/hooks.js +8 -1
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.js +2 -2
- package/dist/personal-access-tokens/handlers/create.write.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
- package/dist/personal-access-tokens/i18n.js +12 -13
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/schema/api-token.js +2 -1
- package/dist/personal-access-tokens/screens.js +5 -1
- package/dist/rate-limiting/feature.js +2 -0
- package/dist/rate-limiting/handlers/bucket-access.js +15 -7
- package/dist/rate-limiting/i18n.d.ts +10 -0
- package/dist/rate-limiting/i18n.js +10 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/secrets/table.d.ts +1 -0
- package/dist/seo/handlers/seo-config.query.js +1 -1
- package/dist/sessions/constants.d.ts +1 -0
- package/dist/sessions/constants.js +1 -0
- package/dist/sessions/feature.js +5 -3
- package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
- package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
- package/dist/sessions/handlers/revoke.write.js +2 -2
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/schema/user-session.js +2 -1
- package/dist/sessions/screens.js +5 -1
- package/dist/sessions/session-callbacks.js +2 -2
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/lockout-counter.js +1 -1
- package/dist/shared/parent-visibility.d.ts +5 -0
- package/dist/shared/parent-visibility.js +17 -1
- package/dist/shared/row-bound-grant.js +20 -9
- package/dist/shared/single-use-token-store.js +5 -13
- package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
- package/dist/step-dispatcher/webhook-runner.js +18 -17
- package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
- package/dist/subscription-mollie/plugin-methods.js +7 -1
- package/dist/subscription-mollie/verify-webhook.js +6 -0
- package/dist/subscription-stripe/feature.js +3 -3
- package/dist/subscription-stripe/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- package/dist/tags/entity.d.ts +9 -0
- package/dist/tags/handlers/assign-tag.write.js +4 -10
- package/dist/tags/handlers/remove-tag.write.js +4 -11
- package/dist/template-resolver/api.d.ts +2 -1
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
- package/dist/template-resolver/handlers/by-slug.query.js +2 -2
- package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
- package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
- package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
- package/dist/template-resolver/handlers/collection-shared.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/list.query.js +1 -1
- package/dist/template-resolver/seeding.js +41 -39
- package/dist/template-resolver/table.d.ts +26 -13
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +16 -9
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver-user-data/hooks.js +1 -1
- package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
- package/dist/tenant/cancel-pending-invitation.js +22 -0
- package/dist/tenant/feature.js +4 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +17 -4
- package/dist/tenant/handlers/remove-member.write.js +9 -14
- package/dist/tenant/handlers/team-list.query.js +2 -2
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +13 -3
- package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +28 -12
- package/dist/tenant/i18n.js +4 -0
- package/dist/tenant/index.d.ts +1 -1
- package/dist/tenant/index.js +1 -1
- package/dist/tenant/invitation-table.d.ts +8 -2
- package/dist/tenant/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +8 -2
- package/dist/tenant/seeding.d.ts +4 -0
- package/dist/tenant/seeding.js +9 -2
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +27 -1
- package/dist/tenant-lifecycle/feature.js +2 -1
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/tenant-settings/feature.js +9 -6
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/feature.js +3 -2
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/command-schemas.d.ts +2 -2
- package/dist/user/handlers/create.write.d.ts +1 -1
- package/dist/user/handlers/create.write.js +8 -2
- package/dist/user/handlers/update.write.d.ts +1 -1
- package/dist/user/handlers/update.write.js +8 -2
- package/dist/user/schema/user.d.ts +14 -2
- package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
- package/dist/user-data-rights/feature.js +22 -9
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
- package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
- package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
- package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
- package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
- package/dist/user-data-rights/run-export-jobs.js +74 -41
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/user-data-rights/run-user-export.js +8 -4
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +8 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
- package/dist/user-profile/feature.js +6 -1
- package/dist/user-profile/handlers/change-email.write.js +2 -0
- package/dist/workflow-runner/db/queries/due-runs.js +6 -9
- package/dist/workflow-runner/event-subscriber.js +29 -9
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
- package/dist/workflow-runner/pending-projection.js +8 -1
- package/package.json +11 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +12 -0
- package/src/delivery/changes.json +7 -0
- package/src/derivatives-sharp/changes.json +11 -0
- package/src/document-ingest-foundation/changes.json +12 -0
- package/src/inbound-mail-foundation/changes.json +12 -0
- package/src/jobs/changes.json +13 -0
- package/src/legal-pages/changes.json +8 -1
- package/src/managed-pages/changes.json +5 -0
- package/src/metrics/changes.json +6 -0
- package/src/rate-limiting/changes.json +5 -0
- package/src/secrets/changes.json +7 -0
- package/src/sessions/changes.json +5 -0
- package/src/shared/changes.json +6 -0
- package/src/step-dispatcher/changes.json +5 -0
- package/src/subscription-mollie/changes.json +7 -0
- package/src/subscription-stripe/changes.json +11 -0
- package/src/template-resolver/changes.json +7 -0
- package/src/tenant/changes.json +29 -0
- package/src/user-data-rights/changes.json +22 -0
- package/src/workflow-runner/changes.json +6 -0
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
export declare const revokeAllForUserWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"user-session:revoke-all-for-user", z.ZodObject<{
|
|
3
3
|
userId: z.ZodString;
|
|
4
4
|
tenantId: z.ZodOptional<z.ZodString>;
|
|
5
|
+
exceptSessionId: z.ZodOptional<z.ZodUUID>;
|
|
5
6
|
}, z.core.$strip>, {
|
|
6
7
|
count: number;
|
|
7
8
|
userId: string;
|
|
@@ -14,7 +14,10 @@ const REVOKE_ALL_SESSIONS_REASON = "revokes the target user's sessions in every
|
|
|
14
14
|
// Art. 18) and potentially other ops flows ("ban user", "compromised
|
|
15
15
|
// account"). Unlike revoke-all-others, the caller's own session (if any)
|
|
16
16
|
// is revoked too — the caller is System (cron/operator/cross-feature),
|
|
17
|
-
// not the end-user themselves.
|
|
17
|
+
// not the end-user themselves. A caller that IS the end-user (an admin
|
|
18
|
+
// editing their own roles) passes `exceptSessionId` to keep that one
|
|
19
|
+
// session alive; the event then carries keptSessionId so the SSE streams of
|
|
20
|
+
// that session stay open too.
|
|
18
21
|
//
|
|
19
22
|
// Tenant-scope: the userSession schema persists tenantId per row (a user
|
|
20
23
|
// can have sessions in multiple tenants). Omitting `tenantId` revokes
|
|
@@ -28,9 +31,10 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
28
31
|
schema: z.object({
|
|
29
32
|
userId: z.string().min(1),
|
|
30
33
|
tenantId: z.string().min(1).optional(),
|
|
34
|
+
exceptSessionId: z.uuid().optional(),
|
|
31
35
|
}),
|
|
32
36
|
access: { roles: access.privileged },
|
|
33
|
-
description: "Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it; use it for operator actions such as freezing or banning an account.",
|
|
37
|
+
description: "Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it, optionally sparing one session (exceptSessionId); use it for operator actions such as freezing or banning an account, or a role change that must not end the editor's own session.",
|
|
34
38
|
agent: { risk: "high" },
|
|
35
39
|
escapeHatch: {
|
|
36
40
|
reason: REVOKE_ALL_SESSIONS_REASON,
|
|
@@ -41,6 +45,9 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
41
45
|
userId: event.payload.userId,
|
|
42
46
|
revokedAt: null,
|
|
43
47
|
...(event.payload.tenantId !== undefined && { tenantId: event.payload.tenantId }),
|
|
48
|
+
...(event.payload.exceptSessionId !== undefined && {
|
|
49
|
+
id: { ne: event.payload.exceptSessionId },
|
|
50
|
+
}),
|
|
44
51
|
});
|
|
45
52
|
// Lightweight append alongside the direct-write above (#1559) — see
|
|
46
53
|
// revoke.write.ts for why this isn't a lifecycle event on
|
|
@@ -68,6 +75,9 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
68
75
|
const payload = sessionRevokedSchema.parse({
|
|
69
76
|
userId: event.payload.userId,
|
|
70
77
|
sessionIds: updated.map((row) => row.id),
|
|
78
|
+
...(event.payload.exceptSessionId !== undefined && {
|
|
79
|
+
keptSessionId: event.payload.exceptSessionId,
|
|
80
|
+
}),
|
|
71
81
|
});
|
|
72
82
|
const reqCtx = requestContext.get();
|
|
73
83
|
await append(runner, {
|
|
@@ -4,7 +4,7 @@ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/
|
|
|
4
4
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import { Temporal } from "temporal-polyfill";
|
|
6
6
|
import * as z from "zod";
|
|
7
|
-
import { SessionErrors } from "../constants.js";
|
|
7
|
+
import { SessionErrors, USER_SESSION_ENTITY_NAME } from "../constants.js";
|
|
8
8
|
import { userSessionTable } from "../schema/user-session.js";
|
|
9
9
|
import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, } from "../session-revoked-event.js";
|
|
10
10
|
// Revoke a single session by id (= JWT jti). Three distinguishable outcomes:
|
|
@@ -66,7 +66,7 @@ export const revokeWrite = defineWriteHandler({
|
|
|
66
66
|
i18nKey: "errors.ownershipDenied",
|
|
67
67
|
details: {
|
|
68
68
|
scope: "entity",
|
|
69
|
-
entityName:
|
|
69
|
+
entityName: USER_SESSION_ENTITY_NAME,
|
|
70
70
|
action: "revoke",
|
|
71
71
|
userId: event.user.id,
|
|
72
72
|
},
|
|
@@ -9,6 +9,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
9
9
|
readonly filterable?: boolean;
|
|
10
10
|
readonly encrypted?: boolean;
|
|
11
11
|
readonly sensitive?: boolean;
|
|
12
|
+
readonly writeOnly?: boolean;
|
|
12
13
|
readonly readAsInstruction?: boolean;
|
|
13
14
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
14
15
|
readonly default?: string;
|
|
@@ -29,6 +30,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
29
30
|
readonly filterable?: boolean;
|
|
30
31
|
readonly encrypted?: boolean;
|
|
31
32
|
readonly sensitive?: boolean;
|
|
33
|
+
readonly writeOnly?: boolean;
|
|
32
34
|
readonly readAsInstruction?: boolean;
|
|
33
35
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
34
36
|
readonly default?: string;
|
|
@@ -109,6 +111,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
109
111
|
readonly filterable?: boolean;
|
|
110
112
|
readonly encrypted?: boolean;
|
|
111
113
|
readonly sensitive?: boolean;
|
|
114
|
+
readonly writeOnly?: boolean;
|
|
112
115
|
readonly readAsInstruction?: boolean;
|
|
113
116
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
114
117
|
readonly default?: string;
|
|
@@ -129,6 +132,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
|
|
|
129
132
|
readonly filterable?: boolean;
|
|
130
133
|
readonly encrypted?: boolean;
|
|
131
134
|
readonly sensitive?: boolean;
|
|
135
|
+
readonly writeOnly?: boolean;
|
|
132
136
|
readonly readAsInstruction?: boolean;
|
|
133
137
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
134
138
|
readonly default?: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, createEntity, createTextField, createTimestampField, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { USER_SESSION_ENTITY_NAME } from "../constants.js";
|
|
3
4
|
// userSession — one row per signed-in client (browser tab, mobile app).
|
|
4
5
|
// The PRIMARY key is the sid — generated by the framework at login time and
|
|
5
6
|
// embedded in the JWT's `jti` claim. Using sid-as-PK means the auth middleware
|
|
@@ -78,6 +79,6 @@ export const userSessionEntity = createEntity({
|
|
|
78
79
|
// revocation is a column write, not an aggregate). Handlers write via ctx.db;
|
|
79
80
|
// the meta carries no executor-only brand so those writes stay legal. See
|
|
80
81
|
// feature.ts for the rebuild-exclusion rationale (#494/#498).
|
|
81
|
-
export const userSessionTable = deriveEntityTableMeta(
|
|
82
|
+
export const userSessionTable = deriveEntityTableMeta(USER_SESSION_ENTITY_NAME, userSessionEntity, {
|
|
82
83
|
source: "unmanaged",
|
|
83
84
|
});
|
package/dist/sessions/screens.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { access, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { access, i18nKey, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SESSION_MINE_SCREEN_ID, SessionHandlers, SessionQueries, } from "./constants.js";
|
|
3
3
|
const listAccess = { roles: access.admin };
|
|
4
4
|
export const sessionListScreen = {
|
|
5
5
|
id: SESSION_LIST_SCREEN_ID,
|
|
6
6
|
type: "projectionList",
|
|
7
7
|
query: SessionQueries.list,
|
|
8
|
+
// The `list` handler honours `limit` only (no offset/total), so a pager would
|
|
9
|
+
// show page 1 forever: send one max-size request and render no pager.
|
|
10
|
+
pagination: false,
|
|
11
|
+
pageSize: MAX_LIST_LIMIT,
|
|
8
12
|
// Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
|
|
9
13
|
// desc) — kept in sync by hand, boot-validator only requires the field
|
|
10
14
|
// be present once the query accepts `sort` (fw#2230).
|
|
@@ -6,7 +6,7 @@ import { Temporal } from "temporal-polyfill";
|
|
|
6
6
|
import { encryptForDirectWrite } from "../shared/index.js";
|
|
7
7
|
import { tenantMembershipsTable } from "../tenant/index.js";
|
|
8
8
|
import { isPrincipalBlocked, userTable } from "../user/index.js";
|
|
9
|
-
import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS } from "./constants.js";
|
|
9
|
+
import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS, USER_SESSION_ENTITY_NAME, } from "./constants.js";
|
|
10
10
|
import { userSessionEntity, userSessionTable } from "./schema/user-session.js";
|
|
11
11
|
import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, sessionRevokedSchema, } from "./session-revoked-event.js";
|
|
12
12
|
// Re-exported so existing `../sessions` importers keep working unchanged.
|
|
@@ -19,7 +19,7 @@ export function createSessionCallbacks(opts) {
|
|
|
19
19
|
const sid = generateId();
|
|
20
20
|
const now = Temporal.Now.instant();
|
|
21
21
|
const expiresAt = now.add({ milliseconds: ttlMs });
|
|
22
|
-
await insertOne(db, userSessionTable, await encryptForDirectWrite(userSessionEntity,
|
|
22
|
+
await insertOne(db, userSessionTable, await encryptForDirectWrite(userSessionEntity, USER_SESSION_ENTITY_NAME, {
|
|
23
23
|
id: sid,
|
|
24
24
|
tenantId: user.tenantId,
|
|
25
25
|
userId: user.id,
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
|
|
|
10
10
|
export { isTenantDb } from "./is-tenant-db.js";
|
|
11
11
|
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.js";
|
|
12
12
|
export { mapWithConcurrency } from "./map-with-concurrency.js";
|
|
13
|
-
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility.js";
|
|
13
|
+
export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisible, } from "./parent-visibility.js";
|
|
14
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
15
15
|
export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
|
|
16
16
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
package/dist/shared/index.js
CHANGED
|
@@ -10,7 +10,7 @@ export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
|
|
|
10
10
|
export { isTenantDb } from "./is-tenant-db.js";
|
|
11
11
|
export { createLockoutCounter } from "./lockout-counter.js";
|
|
12
12
|
export { mapWithConcurrency } from "./map-with-concurrency.js";
|
|
13
|
-
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility.js";
|
|
13
|
+
export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisible, } from "./parent-visibility.js";
|
|
14
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
15
15
|
export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
|
|
16
16
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generic Redis-backed failed-attempt counter with auto-expiring lockout.
|
|
2
2
|
// Extracted from auth-email-password/lockout-store.ts and
|
|
3
|
-
// auth-mfa/mfa-verify-attempts.ts
|
|
3
|
+
// auth-mfa/mfa-verify-attempts.ts — both were byte-identical
|
|
4
4
|
// INCR/NX logic, differing only in their Redis key prefixes.
|
|
5
5
|
// Two keys per subject so each can carry its own TTL:
|
|
6
6
|
// - count-key: 24h, carries the streak. Monotonic — once threshold is
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { type Registry, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
export declare function parentRowIsVisible(registry: Registry, entityType: string, entityId: string, user: SessionUser, db: TenantDb): Promise<boolean>;
|
|
4
5
|
export declare function joinRowParentIsVisible(registry: Registry, joinEntityName: string, payload: Readonly<Record<string, unknown>>, user: SessionUser, db: TenantDb): Promise<boolean>;
|
|
6
|
+
export declare function denyUnlessJoinRowParentVisible(registry: Registry, joinEntityName: string, payload: {
|
|
7
|
+
readonly entityType: string;
|
|
8
|
+
readonly entityId: string;
|
|
9
|
+
}, user: SessionUser, db: TenantDb): Promise<WriteFailure | null>;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createEntityExecutor, isUuid, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
3
|
// Keyed by definition identity, not by entity name — two test stacks can
|
|
3
4
|
// register different EntityDefinitions under the same entity name.
|
|
4
5
|
const executorsByEntity = new WeakMap();
|
|
6
|
+
// Postgres `serial` is int4; a larger digit string would raise 22003 (numeric
|
|
7
|
+
// out of range) inside the write transaction instead of denying cleanly.
|
|
8
|
+
const SERIAL_ID_MAX = 2_147_483_647;
|
|
5
9
|
function idShapeMatchesEntity(entity, entityId) {
|
|
6
|
-
|
|
10
|
+
if (entity.idType !== "serial")
|
|
11
|
+
return isUuid(entityId);
|
|
12
|
+
return /^\d{1,10}$/.test(entityId) && Number(entityId) <= SERIAL_ID_MAX;
|
|
7
13
|
}
|
|
8
14
|
// Checks whether a client-supplied (entityType, entityId) host reference is one
|
|
9
15
|
// the caller can see via that entity's own read path — tenant scope plus its
|
|
@@ -52,3 +58,13 @@ export async function joinRowParentIsVisible(registry, joinEntityName, payload,
|
|
|
52
58
|
}
|
|
53
59
|
return parentRowIsVisible(registry, entityType, entityId, user, db);
|
|
54
60
|
}
|
|
61
|
+
// Write-handler gate over joinRowParentIsVisible: null means proceed, anything
|
|
62
|
+
// else is returned as-is. The host reference is client input, so this must run
|
|
63
|
+
// FIRST in the handler, ahead of every other lookup — a denied caller then
|
|
64
|
+
// can't tell an invisible parent apart from a missing assignment or an unknown
|
|
65
|
+
// tag/folder; every path answers with the same NotFoundError.
|
|
66
|
+
export async function denyUnlessJoinRowParentVisible(registry, joinEntityName, payload, user, db) {
|
|
67
|
+
if (await joinRowParentIsVisible(registry, joinEntityName, payload, user, db))
|
|
68
|
+
return null;
|
|
69
|
+
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
70
|
+
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// user-data-rights' confirm-deletion-by-token and any "you just created this
|
|
4
4
|
// row, now enrich it" flow.
|
|
5
5
|
//
|
|
6
|
-
// The row's own anchor (a value that moves on when the row is consumed
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// The row's own anchor (a value that moves on when the row is consumed) is
|
|
7
|
+
// mixed INTO the HMAC purpose rather than carried in the token. It must be
|
|
8
|
+
// unique per cycle and never recur (request id, UUID, monotonic version): a
|
|
9
|
+
// status can return to an earlier value and revive an old grant (ABA replay).
|
|
10
|
+
// Verification recomputes it from the row's CURRENT anchor, so a replayed
|
|
11
|
+
// token dies the moment the row moves on — single-use semantics without a
|
|
12
|
+
// burn key or Redis. Minting and redeeming
|
|
11
13
|
// share one purpose-building function so the two can't drift apart; that
|
|
12
14
|
// coupling is the whole point of this module, since an unanchored purpose
|
|
13
15
|
// silently degrades to a bearer token valid for the full TTL.
|
|
@@ -27,7 +29,9 @@
|
|
|
27
29
|
// clear. A grant on a row therefore exposes that row's id to whoever holds
|
|
28
30
|
// the link. Where the id itself must stay hidden, use an opaque handle
|
|
29
31
|
// (single-use-token-store) instead.
|
|
32
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
30
33
|
import { peekTokenSubject, signToken, verifyToken } from "./signed-token.js";
|
|
34
|
+
const log = createFallbackLogger("row-bound-grant");
|
|
31
35
|
const FAILED = { ok: false };
|
|
32
36
|
function anchoredPurpose(purpose, anchor) {
|
|
33
37
|
return `${purpose}:${anchor}`;
|
|
@@ -40,6 +44,10 @@ export function signRowBoundGrant(args) {
|
|
|
40
44
|
// hand an attacker a row-existence oracle on an endpoint that is open to
|
|
41
45
|
// anonymous callers.
|
|
42
46
|
export async function redeemRowBoundGrant(args) {
|
|
47
|
+
// Misconfiguration must throw on every call, not only on the first valid redemption.
|
|
48
|
+
if (typeof args.commitAnchor !== "function" && !args.commitAnchor.unsafeSkip.reason.trim()) {
|
|
49
|
+
throw new Error("row-bound grant: unsafeSkip needs a non-empty reason");
|
|
50
|
+
}
|
|
43
51
|
if (!args.secret)
|
|
44
52
|
return FAILED;
|
|
45
53
|
const subject = peekTokenSubject(args.token);
|
|
@@ -52,7 +60,13 @@ export async function redeemRowBoundGrant(args) {
|
|
|
52
60
|
try {
|
|
53
61
|
anchor = await args.loadAnchor(subject);
|
|
54
62
|
}
|
|
55
|
-
catch {
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Name/code only: driver messages can echo the attacker-supplied subject.
|
|
65
|
+
// Logged so an infrastructure outage doesn't look like an expired link.
|
|
66
|
+
log.warn("row-bound grant anchor lookup failed", {
|
|
67
|
+
errorName: error instanceof Error ? error.name : typeof error,
|
|
68
|
+
code: error instanceof Error && "code" in error ? String(error.code) : undefined,
|
|
69
|
+
});
|
|
56
70
|
return FAILED;
|
|
57
71
|
}
|
|
58
72
|
if (!anchor)
|
|
@@ -67,8 +81,5 @@ export async function redeemRowBoundGrant(args) {
|
|
|
67
81
|
if (!spent)
|
|
68
82
|
return FAILED;
|
|
69
83
|
}
|
|
70
|
-
else if (!args.commitAnchor.unsafeSkip.reason.trim()) {
|
|
71
|
-
throw new Error("row-bound grant: unsafeSkip needs a non-empty reason");
|
|
72
|
-
}
|
|
73
84
|
return { ok: true, subject, expiresAtMs: verified.expiresAtMs };
|
|
74
85
|
}
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
// Generic Redis-backed pre-activation token store: bidirectional
|
|
2
2
|
// token↔subject mapping plus single-use burn/unburn semantics. Extracted
|
|
3
3
|
// from auth-email-password/signup-token-store.ts and
|
|
4
|
-
// auth-email-password/invite-token-store.ts
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// auth-email-password/invite-token-store.ts — both were the same Redis
|
|
5
|
+
// layout, differing only in their key prefixes and which field (email vs.
|
|
6
|
+
// invitationId) plays the "subject" role.
|
|
7
7
|
//
|
|
8
8
|
// Public subpath export (./shared/single-use-token-store in package.json):
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// with a comment noting it "must stay byte-compatible with
|
|
12
|
-
// auth-email-password/signup-token-store" because signup-confirm resolves
|
|
13
|
-
// tokens via the same Redis key layout. offlot-app#418 (separate issue,
|
|
14
|
-
// after this ships) will replace that copy with
|
|
15
|
-
// `createSingleUseTokenStore({ tokenPrefix: "signup:by-token:", subjectPrefix:
|
|
16
|
-
// "signup:by-email:", burnPrefix: "signup:burn:" })` — i.e. the exact same
|
|
17
|
-
// prefix strings the framework's own signup store below uses, so both stay
|
|
18
|
-
// byte-compatible by construction instead of by hand-copied logic.
|
|
9
|
+
// external consumers share the signup key layout, so the prefixes must stay
|
|
10
|
+
// byte-identical.
|
|
19
11
|
//
|
|
20
12
|
// Token material: opaque random 256-bit (e.g. crypto.randomBytes,
|
|
21
13
|
// base64url-encoded). Not designed for human typing — the subject clicks a
|
|
@@ -3,8 +3,6 @@ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
export declare const WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS";
|
|
6
|
-
/** Parses the comma-separated operator allowlist env var. Never throws —
|
|
7
|
-
* an unset or empty value just means no bypass. */
|
|
8
6
|
export declare function readAllowedPrivateWebhookHostsFromEnv(env?: Readonly<Record<string, string | undefined>>): readonly string[];
|
|
9
7
|
export declare function setWebhookHostLookup(fn: typeof lookup | undefined): void;
|
|
10
8
|
export declare const WEBHOOK_AUTH_SECRET_KEY_PREFIX = "step-dispatcher:webhook-auth.";
|
|
@@ -22,18 +22,11 @@
|
|
|
22
22
|
import { BlockedHostError, buildPinnedRequest, HostResolutionError, resolvePublicHostname, } from "@cosmicdrift/kumiko-framework/http";
|
|
23
23
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
24
24
|
import * as z from "zod";
|
|
25
|
+
import { isHostAllowlisted, readHostAllowlistFromEnv } from "../foundation-shared/index.js";
|
|
25
26
|
const log = createFallbackLogger("step-dispatcher");
|
|
26
27
|
export const WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS";
|
|
27
|
-
/** Parses the comma-separated operator allowlist env var. Never throws —
|
|
28
|
-
* an unset or empty value just means no bypass. */
|
|
29
28
|
export function readAllowedPrivateWebhookHostsFromEnv(env = process.env) {
|
|
30
|
-
|
|
31
|
-
if (!raw)
|
|
32
|
-
return [];
|
|
33
|
-
return raw
|
|
34
|
-
.split(",")
|
|
35
|
-
.map((host) => host.trim())
|
|
36
|
-
.filter((host) => host.length > 0);
|
|
29
|
+
return readHostAllowlistFromEnv(WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, env);
|
|
37
30
|
}
|
|
38
31
|
// Test-only DNS seam — production never calls this, resolvePublicHostname
|
|
39
32
|
// defaults to the real resolver. Reset it in afterEach/afterAll — this is
|
|
@@ -87,14 +80,23 @@ async function buildWebhookHeaders(spec, deps) {
|
|
|
87
80
|
return { ok: true, headers };
|
|
88
81
|
if (!deps.secrets)
|
|
89
82
|
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
// A throwing get (corrupt envelope, rotated key, KMS error) must become a
|
|
84
|
+
// dispatch-failed event; rethrowing would retry and dead-letter the whole
|
|
85
|
+
// step-dispatcher consumer for every tenant.
|
|
86
|
+
let secret;
|
|
87
|
+
try {
|
|
88
|
+
const revealed = await deps.secrets.get(deps.tenantId, webhookAuthSecretKey(spec.auth.secret), {
|
|
89
|
+
userId: deps.userId,
|
|
90
|
+
handlerName: "step-dispatcher:webhook.send",
|
|
91
|
+
});
|
|
92
|
+
if (!revealed)
|
|
93
|
+
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
94
|
+
secret = revealed.reveal();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
log.warn("webhook auth secret lookup failed", { tenantId: deps.tenantId });
|
|
95
98
|
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
96
99
|
}
|
|
97
|
-
const secret = revealed.reveal();
|
|
98
100
|
if (spec.auth.kind === "bearer") {
|
|
99
101
|
headers["authorization"] = `Bearer ${secret}`;
|
|
100
102
|
}
|
|
@@ -104,8 +106,7 @@ async function buildWebhookHeaders(spec, deps) {
|
|
|
104
106
|
return { ok: true, headers };
|
|
105
107
|
}
|
|
106
108
|
async function resolveWebhookFetchTarget(rawUrl, url, headers) {
|
|
107
|
-
|
|
108
|
-
if (isAllowedPrivateHost)
|
|
109
|
+
if (isHostAllowlisted(url.hostname, readAllowedPrivateWebhookHostsFromEnv()))
|
|
109
110
|
return { ok: true, fetchUrl: rawUrl, requestInit: { headers } };
|
|
110
111
|
try {
|
|
111
112
|
const resolved = await resolvePublicHostname(url.hostname, webhookHostLookup);
|
|
@@ -8,8 +8,9 @@ export type MolliePriceConfig = {
|
|
|
8
8
|
/** Mollie-format: 2-decimal string, z.B. `"9.99"`. */
|
|
9
9
|
readonly amountValue: string;
|
|
10
10
|
readonly amountCurrency: string;
|
|
11
|
-
/** Mollie-format: `1 month` / `1 year` / `14 days`.
|
|
12
|
-
|
|
11
|
+
/** Mollie-format: `1 month` / `1 year` / `14 days`. Required for
|
|
12
|
+
* subscription prices; omit for one-off prices (`mode: "payment"`). */
|
|
13
|
+
readonly interval?: string;
|
|
13
14
|
readonly description: string;
|
|
14
15
|
};
|
|
15
16
|
export declare function createMollieCheckoutSession(client: MollieClient, priceToConfig: Readonly<Record<string, MolliePriceConfig>>, appWebhookUrl: string): (_ctx: HandlerContext, options: MollieCheckoutOptions) => Promise<{
|
|
@@ -19,6 +19,11 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
|
|
|
19
19
|
if (!priceCfg) {
|
|
20
20
|
throw new Error(`subscription-mollie: priceId "${options.priceId}" not in priceToConfig-Map`);
|
|
21
21
|
}
|
|
22
|
+
// A first payment without an interval would set up a mandate that
|
|
23
|
+
// verify-webhook can't turn into a subscription — fail before charging.
|
|
24
|
+
if (options.mode !== "payment" && !priceCfg.interval) {
|
|
25
|
+
throw new Error(`subscription-mollie: priceId "${options.priceId}" has no interval but is booked as a subscription (mode is not "payment")`);
|
|
26
|
+
}
|
|
22
27
|
let customerId = options.providerCustomerId;
|
|
23
28
|
if (!customerId) {
|
|
24
29
|
const customer = await client.customers.create({
|
|
@@ -50,7 +55,8 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
|
|
|
50
55
|
})); // @cast-boundary engine-bridge
|
|
51
56
|
const checkoutHref = payment.getCheckoutUrl();
|
|
52
57
|
if (!checkoutHref) {
|
|
53
|
-
|
|
58
|
+
const mandateHint = sequenceType === SequenceType.first ? " — first-payment-mandates ggf. nicht aktiviert" : "";
|
|
59
|
+
throw new Error(`subscription-mollie: payment.getCheckoutUrl() returned null (sequenceType=${sequenceType})${mandateHint}`);
|
|
54
60
|
}
|
|
55
61
|
return { url: checkoutHref };
|
|
56
62
|
};
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
// das im Plugin (idempotent via list-check), damit Foundation einen
|
|
15
15
|
// Created-Event mit subscription-id bekommt.
|
|
16
16
|
import { SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
17
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
17
18
|
import { Temporal } from "temporal-polyfill";
|
|
18
19
|
import { MOLLIE_PROVIDER_NAME } from "./constants.js";
|
|
20
|
+
const log = createFallbackLogger("subscription-mollie");
|
|
19
21
|
export function verifyAndParseMollieWebhook(client, options) {
|
|
20
22
|
return async (rawBody, headers) => {
|
|
21
23
|
const id = extractMollieId(rawBody, headers);
|
|
@@ -115,6 +117,10 @@ async function ensureSubscriptionForMandate(client, options, payment) {
|
|
|
115
117
|
const priceCfg = options.priceToConfig[priceId];
|
|
116
118
|
if (!priceCfg)
|
|
117
119
|
return null;
|
|
120
|
+
if (!priceCfg.interval) {
|
|
121
|
+
log.warn("price has no interval, no subscription created", { priceId });
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
118
124
|
const existing = await client.customerSubscriptions.list(customerId);
|
|
119
125
|
const matchingExisting = existing.find((sub) => sub.metadata?.["priceId"] === priceId && // @cast-boundary engine-bridge
|
|
120
126
|
(sub.status === "active" || sub.status === "pending"));
|
|
@@ -135,9 +135,9 @@ export function createSubscriptionStripeFeature(options = {}) {
|
|
|
135
135
|
});
|
|
136
136
|
r.translations({
|
|
137
137
|
keys: {
|
|
138
|
-
"subscription-stripe.api-key": { en: "Stripe API
|
|
139
|
-
"subscription-stripe.webhook-secret": { en: "Stripe
|
|
140
|
-
"subscription-stripe.billing-live": { en: "
|
|
138
|
+
"subscription-stripe.api-key": { en: "Stripe API key" },
|
|
139
|
+
"subscription-stripe.webhook-secret": { en: "Stripe webhook secret" },
|
|
140
|
+
"subscription-stripe.billing-live": { en: "Live billing" },
|
|
141
141
|
"subscription-stripe.settings": { en: "Stripe" },
|
|
142
142
|
"screen:subscription-stripe-system.title": { en: "Stripe" },
|
|
143
143
|
},
|
|
@@ -45,6 +45,10 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
|
|
|
45
45
|
: {
|
|
46
46
|
payment_intent_data: { metadata: { tenantId: options.tenantId } },
|
|
47
47
|
invoice_creation: { enabled: paymentInvoiceCreation },
|
|
48
|
+
// Without a customer, Stripe's default "if_required" makes a guest
|
|
49
|
+
// checkout when invoice_creation is off; the webhook then has no
|
|
50
|
+
// customer id and drops the paid payment as ignored.
|
|
51
|
+
...(!options.providerCustomerId && { customer_creation: "always" }),
|
|
48
52
|
}),
|
|
49
53
|
...(options.providerCustomerId && { customer: options.providerCustomerId }),
|
|
50
54
|
});
|
|
@@ -113,12 +117,14 @@ function isKnownRecurringInterval(interval) {
|
|
|
113
117
|
}
|
|
114
118
|
function mapStripePrice(price) {
|
|
115
119
|
const interval = price.recurring?.interval;
|
|
120
|
+
const knownInterval = isKnownRecurringInterval(interval) ? interval : null;
|
|
116
121
|
return {
|
|
117
122
|
priceId: price.id,
|
|
118
123
|
unitAmount: price.unit_amount,
|
|
119
124
|
currency: price.currency,
|
|
120
|
-
interval:
|
|
121
|
-
|
|
125
|
+
interval: knownInterval,
|
|
126
|
+
// Kept consistent with `interval`: a count without an interval would read as a half-recurring price.
|
|
127
|
+
intervalCount: knownInterval ? (price.recurring?.interval_count ?? null) : null,
|
|
122
128
|
active: price.active,
|
|
123
129
|
metadata: price.metadata ?? {},
|
|
124
130
|
};
|
|
@@ -162,11 +168,23 @@ async function resolvePricesViaCache(runtime, cache, ctx, priceIds) {
|
|
|
162
168
|
}
|
|
163
169
|
}
|
|
164
170
|
export function createStripeRetrievePrices(runtime, cache) {
|
|
171
|
+
const warnedUnknownIntervalPriceIds = new Set();
|
|
165
172
|
return async (ctx, priceIds) => {
|
|
166
173
|
await resolvePricesViaCache(runtime, cache, ctx, priceIds);
|
|
167
174
|
return priceIds.flatMap((priceId) => {
|
|
168
175
|
const price = cache.get(priceId);
|
|
169
|
-
|
|
176
|
+
if (!price)
|
|
177
|
+
return [];
|
|
178
|
+
const mapped = mapStripePrice(price);
|
|
179
|
+
const rawInterval = price.recurring?.interval;
|
|
180
|
+
if (rawInterval && mapped.interval === null && !warnedUnknownIntervalPriceIds.has(priceId)) {
|
|
181
|
+
warnedUnknownIntervalPriceIds.add(priceId);
|
|
182
|
+
ctx.log?.warn("subscription-stripe: price has an unknown recurring interval", {
|
|
183
|
+
priceId,
|
|
184
|
+
interval: rawInterval,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return [mapped];
|
|
170
188
|
});
|
|
171
189
|
};
|
|
172
190
|
}
|
|
@@ -175,7 +193,7 @@ function resolveProductId(product) {
|
|
|
175
193
|
return typeof product === "string" ? product : product.id;
|
|
176
194
|
}
|
|
177
195
|
/** Both our own pre-Stripe-call product+interval collision check and a live
|
|
178
|
-
* `StripeInvalidRequestError` from `configurations.create()
|
|
196
|
+
* `StripeInvalidRequestError` from `configurations.create()`
|
|
179
197
|
* surface the same underlying misconfiguration (two plan tiers sharing one
|
|
180
198
|
* Stripe product) — mapped to the same `UnprocessableError` so the panel
|
|
181
199
|
* only needs one i18nKey to translate. */
|
|
@@ -336,8 +354,9 @@ export function createStripePlanSwitchSession(runtime, cache, portalConfigCache)
|
|
|
336
354
|
// out-of-band) must not poison every subsequent switch for the same
|
|
337
355
|
// price-set — evict it so the next call re-searches/re-creates.
|
|
338
356
|
portalConfigCache.delete(hash);
|
|
339
|
-
|
|
340
|
-
|
|
357
|
+
// Not mapped to plan_tiers_share_product: a sessions.create error under
|
|
358
|
+
// flow_data.subscription_update_confirm.* is a different problem (inactive
|
|
359
|
+
// target price, wrong item) and mapping it would hide the real cause.
|
|
341
360
|
throw error;
|
|
342
361
|
}
|
|
343
362
|
};
|
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
// redelivers.
|
|
34
34
|
import { BillingEventKinds, SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
35
35
|
import { ExtraRouteRejection } from "@cosmicdrift/kumiko-framework/api";
|
|
36
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
36
37
|
import Stripe from "stripe";
|
|
37
38
|
import { Temporal } from "temporal-polyfill";
|
|
38
39
|
import { STRIPE_PROVIDER_NAME, StripeEventTypes } from "./constants.js";
|
|
40
|
+
const log = createFallbackLogger("subscription-stripe");
|
|
39
41
|
export function isResourceMissingStripeError(error) {
|
|
40
42
|
return (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing");
|
|
41
43
|
}
|
|
@@ -259,6 +261,12 @@ function isCheckoutSessionEventType(stripeType) {
|
|
|
259
261
|
return (stripeType === StripeEventTypes.checkoutSessionCompleted ||
|
|
260
262
|
stripeType === StripeEventTypes.checkoutSessionAsyncPaymentSucceeded);
|
|
261
263
|
}
|
|
264
|
+
// The session is already paid, so a silent drop means money collected with no
|
|
265
|
+
// credit — warn (ids and reason only, no PII) so operators can spot it.
|
|
266
|
+
function dropPaidSession(event, session, reason) {
|
|
267
|
+
log.warn("paid checkout session dropped", { eventId: event.id, sessionId: session.id, reason });
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
262
270
|
/** Parses a checkout.session.completed / .async_payment_succeeded event into
|
|
263
271
|
* a PaymentEvent, or null if it isn't a paid one-off-payment session. */
|
|
264
272
|
async function parsePaymentEvent(event, stripe) {
|
|
@@ -296,17 +304,16 @@ async function parsePaymentEvent(event, stripe) {
|
|
|
296
304
|
const paymentIntent = expanded.payment_intent;
|
|
297
305
|
if (!paymentIntent || typeof paymentIntent === "string") {
|
|
298
306
|
// Not expanded (Stripe-API-drift) or absent — no verified tenant-claim
|
|
299
|
-
// to trust.
|
|
300
|
-
|
|
301
|
-
return null;
|
|
307
|
+
// to trust.
|
|
308
|
+
return dropPaidSession(event, session, "payment_intent_not_expanded");
|
|
302
309
|
}
|
|
303
310
|
const tenantId = paymentIntent.metadata?.["tenantId"];
|
|
304
311
|
if (!tenantId || tenantId.length === 0) {
|
|
305
|
-
return
|
|
312
|
+
return dropPaidSession(event, session, "missing_tenant_id");
|
|
306
313
|
}
|
|
307
314
|
const priceId = expanded.line_items?.data[0]?.price?.id;
|
|
308
315
|
if (!priceId) {
|
|
309
|
-
return
|
|
316
|
+
return dropPaidSession(event, session, "missing_price_id");
|
|
310
317
|
}
|
|
311
318
|
// providerCustomerId: session.customer is null for guest checkouts (no
|
|
312
319
|
// customer_creation configured) — fall back to the PaymentIntent's own
|
|
@@ -317,7 +324,7 @@ async function parsePaymentEvent(event, stripe) {
|
|
|
317
324
|
? paymentIntent.customer
|
|
318
325
|
: paymentIntent.customer?.id);
|
|
319
326
|
if (!providerCustomerId) {
|
|
320
|
-
return
|
|
327
|
+
return dropPaidSession(event, session, "missing_customer");
|
|
321
328
|
}
|
|
322
329
|
return {
|
|
323
330
|
kind: BillingEventKinds.payment,
|