@cosmicdrift/kumiko-samples 0.404.1 → 0.405.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 +1 -1
- package/packages/bundled-features/package.json +10 -10
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
- package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
- package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
- package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
- package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
- package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
- package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
- package/packages/bundled-features/src/folders/feature.ts +2 -8
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
- package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
- package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
- package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
- package/packages/bundled-features/src/ledger/feature.ts +2 -7
- package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
- package/packages/bundled-features/src/notes-history/feature.ts +7 -8
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
- package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
- package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
- package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
- package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
- package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
- package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
- package/packages/bundled-features/src/shared/signed-token.ts +4 -14
- package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
- package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
- package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
- package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
- package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
- package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
- package/packages/bundled-features/src/tags/feature.ts +2 -8
- package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
- package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
- package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
- package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
|
@@ -4,6 +4,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
|
4
4
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { isExpiredAt } from "./expiry.ts";
|
|
6
6
|
import { hashPatToken } from "./hash.ts";
|
|
7
|
+
import { parseScopeNames } from "./parse-scope-names.ts";
|
|
7
8
|
import { resolvePatRoles } from "./roles.ts";
|
|
8
9
|
import { apiTokenTable } from "./schema/api-token.ts";
|
|
9
10
|
import { expandScopes, type PatScopeConfig } from "./web/scopes.ts";
|
|
@@ -42,12 +43,3 @@ export function createPatResolver(opts: {
|
|
|
42
43
|
};
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
function parseScopeNames(raw: string): string[] {
|
|
47
|
-
try {
|
|
48
|
-
const parsed: unknown = JSON.parse(raw);
|
|
49
|
-
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
50
|
-
} catch {
|
|
51
|
-
return [];
|
|
52
|
-
}
|
|
53
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { collectExtensionOptions } from "../shared/index.ts";
|
|
2
3
|
import { RENDERER_EXTENSION } from "./constants.ts";
|
|
3
4
|
import { isRendererRegistrationPlugin, type RendererPlugin } from "./types.ts";
|
|
4
5
|
|
|
@@ -22,13 +23,7 @@ export function createRendererFoundationFeature() {
|
|
|
22
23
|
});
|
|
23
24
|
r.requires({ features: ["template-resolver"] });
|
|
24
25
|
|
|
25
|
-
r.extendsRegistrar(RENDERER_EXTENSION, {
|
|
26
|
-
onRegister: () => {
|
|
27
|
-
// Plugin conformance check could go here: validate the shape of
|
|
28
|
-
// options (kinds, render function present). No shape check currently —
|
|
29
|
-
// the caller's TypeScript type safety applies.
|
|
30
|
-
},
|
|
31
|
-
});
|
|
26
|
+
r.extendsRegistrar(RENDERER_EXTENSION, {});
|
|
32
27
|
|
|
33
28
|
return {};
|
|
34
29
|
});
|
|
@@ -40,13 +35,5 @@ export function createRendererFoundationFeature() {
|
|
|
40
35
|
//
|
|
41
36
|
// Symmetric to collectChannels / collectRenderers in delivery-service.
|
|
42
37
|
export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
|
|
43
|
-
|
|
44
|
-
return usages.map((usage) => {
|
|
45
|
-
if (!isRendererRegistrationPlugin(usage.options)) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
`${RENDERER_EXTENSION} registration for "${usage.entityName}" has invalid options`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return { name: usage.entityName, ...usage.options };
|
|
51
|
-
});
|
|
38
|
+
return collectExtensionOptions(registry, RENDERER_EXTENSION, isRendererRegistrationPlugin);
|
|
52
39
|
}
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
createTenantDb,
|
|
23
23
|
type DbConnection,
|
|
24
24
|
selectMany,
|
|
25
|
-
type TenantDb,
|
|
26
25
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
27
26
|
import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
27
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -31,7 +30,11 @@ import {
|
|
|
31
30
|
encodeStoredEnvelope,
|
|
32
31
|
rewrapDek,
|
|
33
32
|
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
34
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
type ChunkedMigrationStopReason,
|
|
35
|
+
createTenantDbCache,
|
|
36
|
+
runChunkedMigration,
|
|
37
|
+
} from "../../shared/index.ts";
|
|
35
38
|
import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table.ts";
|
|
36
39
|
|
|
37
40
|
const DEFAULT_BATCH_SIZE = 100;
|
|
@@ -74,23 +77,14 @@ export async function rotateJob(
|
|
|
74
77
|
? Date.now() + payload.maxDurationMs
|
|
75
78
|
: Number.POSITIVE_INFINITY;
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
db,
|
|
86
|
-
tenantId,
|
|
87
|
-
mode: "system",
|
|
88
|
-
reason: "secret key rotation job re-encrypts secrets of every tenant",
|
|
89
|
-
});
|
|
90
|
-
tdbCache.set(tenantId, existing);
|
|
91
|
-
}
|
|
92
|
-
return existing;
|
|
93
|
-
}
|
|
80
|
+
const tdbFor = createTenantDbCache((tenantId) =>
|
|
81
|
+
createTenantDb({
|
|
82
|
+
db,
|
|
83
|
+
tenantId,
|
|
84
|
+
mode: "system",
|
|
85
|
+
reason: "secret key rotation job re-encrypts secrets of every tenant",
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
94
88
|
|
|
95
89
|
type SecretRow = {
|
|
96
90
|
id: string;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.405.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Bundled features share internal helpers",
|
|
6
|
+
"detail": "Access resolution for folders, ledger, notes-history and tags, the folders/tags write hook, the system-mode TenantDb cache of the re-encryption and rotation jobs, the mail locale templates of auth-email-password and user-data-rights, extension option collection for delivery and renderers, PAT scope parsing, user-data-rights hook DBs, unique-constraint failure checks, tenant member PII decryption, the status banner, the invite-accept request, the tenant label and the jobs subject parser each live in one internal module now. Empty onRegister callbacks are dropped. No behavior change."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.364.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export function collectExtensionOptions<TOptions extends object>(
|
|
4
|
+
registry: Registry,
|
|
5
|
+
extension: string,
|
|
6
|
+
isOptions: (candidate: unknown) => candidate is TOptions,
|
|
7
|
+
): Array<{ name: string } & TOptions> {
|
|
8
|
+
return registry.getExtensionUsages(extension).map((usage) => {
|
|
9
|
+
if (!isOptions(usage.options)) {
|
|
10
|
+
throw new Error(`${extension} registration for "${usage.entityName}" has invalid options`);
|
|
11
|
+
}
|
|
12
|
+
return { name: usage.entityName, ...usage.options };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function signHmacSha256Base64Url(input: string, secret: string): string {
|
|
4
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function hmacSignatureMatches(
|
|
8
|
+
expectedBase64Url: string,
|
|
9
|
+
providedBase64Url: string,
|
|
10
|
+
): boolean {
|
|
11
|
+
const expectedBuf = Buffer.from(expectedBase64Url, "base64url");
|
|
12
|
+
const providedBuf = Buffer.from(providedBase64Url, "base64url");
|
|
13
|
+
// timingSafeEqual throws on a length mismatch, and the throw itself is a timing signal.
|
|
14
|
+
return expectedBuf.length === providedBuf.length && timingSafeEqual(expectedBuf, providedBuf);
|
|
15
|
+
}
|
|
@@ -15,13 +15,17 @@ export {
|
|
|
15
15
|
classifyStoredEnvelope,
|
|
16
16
|
type StoredEnvelopeClassification,
|
|
17
17
|
} from "./classify-stored-envelope.ts";
|
|
18
|
+
export { collectExtensionOptions } from "./collect-extension-options.ts";
|
|
18
19
|
export { decryptStoredPii, decryptStoredUserEmail } from "./decrypt-stored-pii.ts";
|
|
19
20
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write.ts";
|
|
20
21
|
export { entitiesOf } from "./entities-of.ts";
|
|
21
22
|
export { isWithinGracePeriod } from "./grace-period.ts";
|
|
23
|
+
export { hmacSignatureMatches, signHmacSha256Base64Url } from "./hmac-signature.ts";
|
|
22
24
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.ts";
|
|
23
25
|
export { isTenantDb } from "./is-tenant-db.ts";
|
|
26
|
+
export { isUniqueConstraintFailure } from "./is-unique-constraint-failure.ts";
|
|
24
27
|
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.ts";
|
|
28
|
+
export { createMailLocaleTemplates } from "./mail-locale-templates.ts";
|
|
25
29
|
export {
|
|
26
30
|
mailHeading,
|
|
27
31
|
mailParagraph,
|
|
@@ -59,6 +63,7 @@ export {
|
|
|
59
63
|
} from "./redact.ts";
|
|
60
64
|
export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.ts";
|
|
61
65
|
export { requireForTenant } from "./require-for-tenant.ts";
|
|
66
|
+
export { resolveFeatureAccess } from "./resolve-feature-access.ts";
|
|
62
67
|
export {
|
|
63
68
|
extensionForResourceContentType,
|
|
64
69
|
isResourceImageContentType,
|
|
@@ -108,4 +113,5 @@ export {
|
|
|
108
113
|
stepFailures,
|
|
109
114
|
} from "./stream-tenant-derived-state.ts";
|
|
110
115
|
export type { SystemQueryFn } from "./system-query.ts";
|
|
116
|
+
export { createTenantDbCache } from "./tenant-db-cache.ts";
|
|
111
117
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store.ts";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
|
+
|
|
4
|
+
// The executor reports a unique-index race as a WriteFailure(unique_violation)
|
|
5
|
+
// whose details name the violated constraint.
|
|
6
|
+
export function isUniqueConstraintFailure(failure: WriteFailure, constraint: string): boolean {
|
|
7
|
+
return (
|
|
8
|
+
propertyOf(failure.error, "code") === "unique_violation" &&
|
|
9
|
+
propertyOf(propertyOf(failure.error, "details"), "constraintName") === constraint
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { NotificationLocalizedTemplate } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
hasMailTranslationKey,
|
|
4
|
+
listMailLocales,
|
|
5
|
+
mailT,
|
|
6
|
+
resolveMailLocale,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/i18n";
|
|
8
|
+
|
|
9
|
+
type MailLocaleTemplateConfig<Kind extends string> = {
|
|
10
|
+
readonly kinds: readonly Kind[];
|
|
11
|
+
readonly notificationTypes: Readonly<Record<Kind, string>>;
|
|
12
|
+
readonly subjectKey: (kind: Kind) => string;
|
|
13
|
+
readonly bodyFor: (kind: Kind, locale: string) => string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createMailLocaleTemplates<Kind extends string>(
|
|
17
|
+
config: MailLocaleTemplateConfig<Kind>,
|
|
18
|
+
): {
|
|
19
|
+
templateLocale: (notificationType: string, requestedLocale: string) => string;
|
|
20
|
+
localizedTemplate: (kind: Kind) => () => NotificationLocalizedTemplate;
|
|
21
|
+
} {
|
|
22
|
+
const subjectKeyByType: ReadonlyMap<string, string> = new Map(
|
|
23
|
+
config.kinds.map((kind) => [config.notificationTypes[kind], config.subjectKey(kind)]),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// A registered locale whose bundle lacks the subject key has no template (see
|
|
27
|
+
// localizedTemplate); the renderer has no locale fallback, so the sender must
|
|
28
|
+
// pick a locale that has one or the mail would not go out at all.
|
|
29
|
+
const templateLocale = (notificationType: string, requestedLocale: string): string => {
|
|
30
|
+
const locale = resolveMailLocale(requestedLocale);
|
|
31
|
+
const key = subjectKeyByType.get(notificationType);
|
|
32
|
+
return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Evaluated when the registry is built, after every locale feature has
|
|
36
|
+
// registered its mail translations. English always ships; another locale only
|
|
37
|
+
// gets a template when it has its own subject, so a missing translation never
|
|
38
|
+
// silently becomes an English mail in that locale's slot.
|
|
39
|
+
const localizedTemplate = (kind: Kind): (() => NotificationLocalizedTemplate) => {
|
|
40
|
+
const kindSubjectKey = config.subjectKey(kind);
|
|
41
|
+
return () => {
|
|
42
|
+
const locales = [
|
|
43
|
+
"en",
|
|
44
|
+
...listMailLocales().filter(
|
|
45
|
+
(locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
|
|
46
|
+
),
|
|
47
|
+
];
|
|
48
|
+
return Object.fromEntries(
|
|
49
|
+
locales.map((locale) => [
|
|
50
|
+
locale,
|
|
51
|
+
{
|
|
52
|
+
body: config.bodyFor(kind, locale),
|
|
53
|
+
meta: {
|
|
54
|
+
subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
|
|
55
|
+
category: "transactional" as const,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
]),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return { templateLocale, localizedTemplate };
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
type FeatureAccessOptions = {
|
|
4
|
+
readonly access?: AccessRule;
|
|
5
|
+
readonly roles?: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// `access` wins over the `roles` shorthand; neither set falls back to the feature default.
|
|
9
|
+
export function resolveFeatureAccess(
|
|
10
|
+
opts: FeatureAccessOptions,
|
|
11
|
+
defaultAccess: AccessRule,
|
|
12
|
+
): AccessRule {
|
|
13
|
+
if (opts.access !== undefined) return opts.access;
|
|
14
|
+
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
15
|
+
return defaultAccess;
|
|
16
|
+
}
|
|
@@ -12,17 +12,13 @@
|
|
|
12
12
|
// Timing-safe comparison on verify so a valid-length forgery can't leak
|
|
13
13
|
// signal through a short-circuit.
|
|
14
14
|
|
|
15
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
16
15
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
16
|
+
import { hmacSignatureMatches, signHmacSha256Base64Url } from "./hmac-signature.ts";
|
|
17
17
|
|
|
18
18
|
export type VerifyResult =
|
|
19
19
|
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
20
20
|
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
21
21
|
|
|
22
|
-
function sign(input: string, secret: string): string {
|
|
23
|
-
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
22
|
function payload(purpose: string, userId: string, expiresAtMs: number): string {
|
|
27
23
|
return `${purpose}:${userId}.${expiresAtMs}`;
|
|
28
24
|
}
|
|
@@ -36,7 +32,7 @@ export function signToken(
|
|
|
36
32
|
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
33
|
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
38
34
|
const expiresAtMs = expiresAt.epochMilliseconds;
|
|
39
|
-
const signature =
|
|
35
|
+
const signature = signHmacSha256Base64Url(payload(purpose, userId, expiresAtMs), secret);
|
|
40
36
|
return {
|
|
41
37
|
token: `${userId}.${expiresAtMs}.${signature}`,
|
|
42
38
|
expiresAt,
|
|
@@ -74,14 +70,8 @@ export function verifyToken(
|
|
|
74
70
|
return { ok: false, reason: "malformed" };
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
const expected =
|
|
78
|
-
|
|
79
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
80
|
-
// Length mismatch fails BEFORE timingSafeEqual, which throws on different
|
|
81
|
-
// lengths — but that throw itself leaks via timing. Explicit length check
|
|
82
|
-
// keeps the path uniform.
|
|
83
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
84
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
73
|
+
const expected = signHmacSha256Base64Url(payload(purpose, userId, expiresAtMs), secret);
|
|
74
|
+
if (!hmacSignatureMatches(expected, providedSig)) return { ok: false, reason: "bad_signature" };
|
|
85
75
|
|
|
86
76
|
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
|
|
87
77
|
return { ok: false, reason: "expired" };
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
// permanently tax Redis, long enough to catch replays inside any realistic
|
|
39
39
|
// race window).
|
|
40
40
|
|
|
41
|
-
import {
|
|
41
|
+
import { hashOpaqueTokenSync } from "@cosmicdrift/kumiko-framework/crypto";
|
|
42
42
|
import type { Redis } from "ioredis";
|
|
43
43
|
|
|
44
44
|
// Exported so a caller that needs a byte-compatible key in a sibling Redis
|
|
45
45
|
// namespace (e.g. a token↔binding side-mapping) can derive it without
|
|
46
46
|
// re-implementing sha256-of-token — see auth-email-password/signup-token-store.ts.
|
|
47
47
|
export function hashSingleUseToken(token: string): string {
|
|
48
|
-
return
|
|
48
|
+
return hashOpaqueTokenSync(token);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function createSingleUseTokenStore(prefixes: {
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
6
6
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isEntitySearchable,
|
|
10
|
+
reindexEntity,
|
|
11
|
+
type SearchAdapter,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/search";
|
|
9
13
|
import type { StreamBackfillCandidate, StreamBackfillFailure } from "./stream-tenant-backfill.ts";
|
|
10
14
|
|
|
11
15
|
export type StreamBackfillStepFailure = StreamBackfillFailure & { readonly step: string };
|
|
@@ -88,13 +92,6 @@ export async function rebuildAndReindex(deps: {
|
|
|
88
92
|
return { projectionsRebuilt, reindexedRows, failures };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
function isEntitySearchable(registry: Registry, entityName: string): boolean {
|
|
92
|
-
return (
|
|
93
|
-
registry.getSearchableFields(entityName).length > 0 ||
|
|
94
|
-
registry.getSearchPayloadExtensions(entityName).length > 0
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
95
|
export function stepFailures(
|
|
99
96
|
step: string,
|
|
100
97
|
failed: readonly StreamBackfillFailure[],
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
4
|
+
// One TenantDb per tenant, so a job touching many rows of the same tenant does
|
|
5
|
+
// not rebuild the wrapper per row. The caller builds the TenantDb itself, so
|
|
6
|
+
// its createTenantDb call keeps the literal system-mode reason the guard needs.
|
|
7
|
+
export function createTenantDbCache(
|
|
8
|
+
create: (tenantId: TenantId) => TenantDb,
|
|
9
|
+
): (tenantId: TenantId) => TenantDb {
|
|
10
|
+
const cache = new Map<TenantId, TenantDb>();
|
|
11
|
+
return (tenantId) => {
|
|
12
|
+
let existing = cache.get(tenantId);
|
|
13
|
+
if (!existing) {
|
|
14
|
+
existing = create(tenantId);
|
|
15
|
+
cache.set(tenantId, existing);
|
|
16
|
+
}
|
|
17
|
+
return existing;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export type SectionStatus =
|
|
5
|
+
| { kind: "idle" }
|
|
6
|
+
| { kind: "submitting" }
|
|
7
|
+
| { kind: "success"; messageKey: string }
|
|
8
|
+
| { kind: "error"; messageKey: string };
|
|
9
|
+
|
|
10
|
+
export function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
const { Banner } = usePrimitives();
|
|
13
|
+
if (status.kind === "success") {
|
|
14
|
+
return <Banner variant="info">{t(status.messageKey)}</Banner>;
|
|
15
|
+
}
|
|
16
|
+
if (status.kind === "error") {
|
|
17
|
+
return <Banner variant="error">{t(status.messageKey)}</Banner>;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useDispatcher } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
// Busy flag + error key shared by the folders/tags management surfaces: every
|
|
5
|
+
// write goes through the dispatcher and a failed one surfaces its i18n key.
|
|
6
|
+
export function useDispatchedWrites(onWriteSucceeded?: () => Promise<void> | void) {
|
|
7
|
+
const dispatcher = useDispatcher();
|
|
8
|
+
const [busy, setBusy] = useState(false);
|
|
9
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
12
|
+
const result = await dispatcher.write(type, payload);
|
|
13
|
+
if (!result.isSuccess) {
|
|
14
|
+
setErrorKey(result.error.i18nKey);
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const apply = async (write: () => Promise<boolean>): Promise<void> => {
|
|
21
|
+
setBusy(true);
|
|
22
|
+
setErrorKey(null);
|
|
23
|
+
try {
|
|
24
|
+
if (await write()) await onWriteSucceeded?.();
|
|
25
|
+
} finally {
|
|
26
|
+
setBusy(false);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return { busy, setBusy, errorKey, setErrorKey, writeOk, apply };
|
|
31
|
+
}
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// subscription-stripe. The `apiKey` / `webhookSecret` / `tenantBindingKey` /
|
|
7
7
|
// `clientToken` options are env→config bridge fallbacks.
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
type SubscriptionProviderPlugin,
|
|
11
|
+
validateOneOffPriceIds,
|
|
12
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
10
13
|
import {
|
|
11
14
|
access,
|
|
12
15
|
createSystemConfig,
|
|
@@ -122,25 +125,10 @@ export type SubscriptionPaddleOptions = {
|
|
|
122
125
|
readonly nowSeconds?: () => number;
|
|
123
126
|
};
|
|
124
127
|
|
|
125
|
-
// Validated once at feature-build time so a typo fails app startup instead of
|
|
126
|
-
// silently widening or narrowing the one-off allowlist at request time.
|
|
127
|
-
function validateOneOffPriceIds(priceIds: readonly string[]): void {
|
|
128
|
-
const seen = new Set<string>();
|
|
129
|
-
for (const priceId of priceIds) {
|
|
130
|
-
if (priceId.length === 0) {
|
|
131
|
-
throw new Error("subscription-paddle: oneOffPriceIds must not contain an empty string");
|
|
132
|
-
}
|
|
133
|
-
if (seen.has(priceId)) {
|
|
134
|
-
throw new Error(`subscription-paddle: duplicate oneOffPriceIds entry "${priceId}"`);
|
|
135
|
-
}
|
|
136
|
-
seen.add(priceId);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
128
|
export function createSubscriptionPaddleFeature(
|
|
141
129
|
options: SubscriptionPaddleOptions = {},
|
|
142
130
|
): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionPaddleEnvSchema.shape> {
|
|
143
|
-
validateOneOffPriceIds(options.oneOffPriceIds ?? []);
|
|
131
|
+
validateOneOffPriceIds("subscription-paddle", options.oneOffPriceIds ?? []);
|
|
144
132
|
const now = options.now ?? (() => Temporal.Now.instant().epochMilliseconds);
|
|
145
133
|
const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_PADDLE_FEATURE, (r) => {
|
|
146
134
|
r.describe(
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// cache misses, cached per price id.
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
createTtlPriceCache,
|
|
5
6
|
KNOWN_RECURRING_INTERVALS,
|
|
6
7
|
type ProviderPrice,
|
|
8
|
+
type TtlPriceCache,
|
|
7
9
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
8
10
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { chunk } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
12
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
10
13
|
import * as z from "zod";
|
|
11
14
|
import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
|
|
@@ -61,10 +64,7 @@ function mapPaddlePrice(price: PaddlePrice): ProviderPrice {
|
|
|
61
64
|
return { ...base, interval, intervalCount: interval ? cycle.frequency : null };
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
export type PaddlePriceCache =
|
|
65
|
-
readonly get: (priceId: string) => ProviderPrice | undefined;
|
|
66
|
-
readonly set: (priceId: string, price: ProviderPrice) => void;
|
|
67
|
-
};
|
|
67
|
+
export type PaddlePriceCache = TtlPriceCache<ProviderPrice>;
|
|
68
68
|
|
|
69
69
|
export type PaddlePriceCacheOptions = {
|
|
70
70
|
readonly ttlMs?: number;
|
|
@@ -75,32 +75,10 @@ export type PaddlePriceCacheOptions = {
|
|
|
75
75
|
// trigger an audited secret read on every billing-plans render. Failed lookups
|
|
76
76
|
// are never cached.
|
|
77
77
|
export function createPaddlePriceCache({
|
|
78
|
-
ttlMs
|
|
78
|
+
ttlMs,
|
|
79
79
|
now = () => Temporal.Now.instant().epochMilliseconds,
|
|
80
80
|
}: PaddlePriceCacheOptions = {}): PaddlePriceCache {
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
get: (priceId) => {
|
|
84
|
-
const entry = store.get(priceId);
|
|
85
|
-
if (!entry) return undefined;
|
|
86
|
-
if (entry.expiresAt <= now()) {
|
|
87
|
-
store.delete(priceId);
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
return entry.price;
|
|
91
|
-
},
|
|
92
|
-
set: (priceId, price) => {
|
|
93
|
-
store.set(priceId, { price, expiresAt: now() + ttlMs });
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function chunked<T>(items: readonly T[], size: number): T[][] {
|
|
99
|
-
const chunks: T[][] = [];
|
|
100
|
-
for (let start = 0; start < items.length; start += size) {
|
|
101
|
-
chunks.push(items.slice(start, start + size));
|
|
102
|
-
}
|
|
103
|
-
return chunks;
|
|
81
|
+
return createTtlPriceCache<ProviderPrice>({ ttlMs, now });
|
|
104
82
|
}
|
|
105
83
|
|
|
106
84
|
async function fillPriceCache(
|
|
@@ -111,7 +89,7 @@ async function fillPriceCache(
|
|
|
111
89
|
): Promise<void> {
|
|
112
90
|
const misses = [...new Set(priceIds.filter((priceId) => cache.get(priceId) === undefined))];
|
|
113
91
|
const settled = await Promise.allSettled(
|
|
114
|
-
|
|
92
|
+
chunk(misses, PRICES_PER_REQUEST).map(async (ids) =>
|
|
115
93
|
pricesResponseSchema.parse(
|
|
116
94
|
await runtime.call(ctx, {
|
|
117
95
|
method: "GET",
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// HandlerContext; the pre-tenant webhook reads secrets un-audited from the
|
|
8
8
|
// system SecretsContext the foundation passes in.
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
parseStoredSecret,
|
|
12
|
+
type SubscriptionProviderPlugin,
|
|
13
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
11
14
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
12
15
|
import {
|
|
13
16
|
type ConfigKeyHandle,
|
|
@@ -16,7 +19,6 @@ import {
|
|
|
16
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
20
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
18
21
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
19
|
-
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
20
22
|
import { createPaddleHttp, type PaddleRequest, type PaddleSend } from "./client.ts";
|
|
21
23
|
import {
|
|
22
24
|
PADDLE_BASE_URLS,
|
|
@@ -47,19 +49,6 @@ export type PaddleRuntimeDeps = {
|
|
|
47
49
|
readonly fallback: PaddleFallbacks;
|
|
48
50
|
};
|
|
49
51
|
|
|
50
|
-
// backing:"secrets" values are stored JSON-serialized, so the store read has to
|
|
51
|
-
// be parsed back; the env fallback is a plain string and stays raw. The error
|
|
52
|
-
// must not carry the input: the JSON parser's own message would repeat the
|
|
53
|
-
// credential into logs and, for webhooks, into the 401 body.
|
|
54
|
-
function parseStoredSecret(raw: string | undefined): string | undefined {
|
|
55
|
-
if (raw === undefined || raw.length === 0) return undefined;
|
|
56
|
-
const parsed = parseJsonSafe<unknown>(raw, undefined);
|
|
57
|
-
if (parsed === undefined) {
|
|
58
|
-
throw new Error("subscription-paddle credential (backing:secrets) is not valid JSON");
|
|
59
|
-
}
|
|
60
|
-
return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
function hintFor(shortName: string): string {
|
|
64
53
|
return `Set it via config:write:set on \`${SUBSCRIPTION_PADDLE_FEATURE}:config:${shortName}\` (or seed it from the matching PADDLE_* variable during the env bridge).`;
|
|
65
54
|
}
|
|
@@ -108,7 +97,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
|
|
|
108
97
|
{ secrets: ctx.services.secrets, user: ctx.user },
|
|
109
98
|
SUBSCRIPTION_PADDLE_FEATURE,
|
|
110
99
|
).get(SYSTEM_TENANT_ID, handle.name);
|
|
111
|
-
value = parseStoredSecret(got?.reveal());
|
|
100
|
+
value = parseStoredSecret("subscription-paddle", got?.reveal());
|
|
112
101
|
}
|
|
113
102
|
if (!value && fallback && fallback.length > 0) value = fallback;
|
|
114
103
|
if (!value) {
|
|
@@ -129,7 +118,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
|
|
|
129
118
|
if (systemSecrets) {
|
|
130
119
|
// No auditCtx: every webhook would otherwise spam the audit trail.
|
|
131
120
|
const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
|
|
132
|
-
const stored = parseStoredSecret(got?.reveal());
|
|
121
|
+
const stored = parseStoredSecret("subscription-paddle", got?.reveal());
|
|
133
122
|
if (stored) return stored;
|
|
134
123
|
}
|
|
135
124
|
if (fallback && fallback.length > 0) return fallback;
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
// api key + webhook secret without auditing (a sanctioned framework-internal
|
|
33
33
|
// read).
|
|
34
34
|
|
|
35
|
-
import
|
|
35
|
+
import {
|
|
36
|
+
type SubscriptionProviderPlugin,
|
|
37
|
+
validateOneOffPriceIds,
|
|
38
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
36
39
|
import {
|
|
37
40
|
access,
|
|
38
41
|
createSystemConfig,
|
|
@@ -144,23 +147,6 @@ export type SubscriptionStripeOptions = {
|
|
|
144
147
|
readonly oneOffPriceIds?: readonly string[];
|
|
145
148
|
};
|
|
146
149
|
|
|
147
|
-
// Validated once at feature-build time (not per-request) so a config typo
|
|
148
|
-
// (a pasted-twice priceId, a blank string from a bad env-join) fails app
|
|
149
|
-
// startup instead of silently widening — or narrowing — the one-off
|
|
150
|
-
// allowlist at request time. Mirrors legal-pages' validateRoutes.
|
|
151
|
-
function validateOneOffPriceIds(priceIds: readonly string[]): void {
|
|
152
|
-
const seen = new Set<string>();
|
|
153
|
-
for (const priceId of priceIds) {
|
|
154
|
-
if (priceId.length === 0) {
|
|
155
|
-
throw new Error("subscription-stripe: oneOffPriceIds must not contain an empty string");
|
|
156
|
-
}
|
|
157
|
-
if (seen.has(priceId)) {
|
|
158
|
-
throw new Error(`subscription-stripe: duplicate oneOffPriceIds entry "${priceId}"`);
|
|
159
|
-
}
|
|
160
|
-
seen.add(priceId);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
150
|
/**
|
|
165
151
|
* Factory for the subscription-stripe feature. Always mounts (no
|
|
166
152
|
* key-presence guard) — activity is gated at runtime through config/secrets
|
|
@@ -171,7 +157,7 @@ function validateOneOffPriceIds(priceIds: readonly string[]): void {
|
|
|
171
157
|
export function createSubscriptionStripeFeature(
|
|
172
158
|
options: SubscriptionStripeOptions = {},
|
|
173
159
|
): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionStripeEnvSchema.shape> {
|
|
174
|
-
validateOneOffPriceIds(options.oneOffPriceIds ?? []);
|
|
160
|
+
validateOneOffPriceIds("subscription-stripe", options.oneOffPriceIds ?? []);
|
|
175
161
|
const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
|
|
176
162
|
r.describe(
|
|
177
163
|
'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
|