@cosmicdrift/kumiko-samples 0.404.0 → 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 +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- 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/auth-gate.tsx +4 -0
- 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/documents/changes.json +8 -0
- 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/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- 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/legal-pages/README.md +21 -13
- 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/README.md +6 -4
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- 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/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- 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/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- 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-data-rights/web/public-deletion-gate.tsx +1 -0
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
import { createLruCache } from "@cosmicdrift/kumiko-framework/utils";
|
|
2
3
|
import { renderSafeMarkdown } from "../page-render/index.ts";
|
|
3
4
|
import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
|
|
4
5
|
import type { BodyFormat } from "./constants.ts";
|
|
@@ -29,8 +30,7 @@ export type PageBodyRendererDeps = {
|
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
// Every write bumps the version, so a (page, version) key never serves stale
|
|
32
|
-
// HTML.
|
|
33
|
-
// key the least recently used one.
|
|
33
|
+
// HTML.
|
|
34
34
|
function cacheKeyOf(page: RenderablePageBody): string {
|
|
35
35
|
return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join(
|
|
36
36
|
"\u0000",
|
|
@@ -47,7 +47,7 @@ export function createPageBodyRenderer(
|
|
|
47
47
|
const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
|
|
48
48
|
const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
|
|
49
49
|
const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
|
|
50
|
-
const cache =
|
|
50
|
+
const cache = createLruCache<string, string>({ maxEntries });
|
|
51
51
|
|
|
52
52
|
function renderUncached(page: RenderablePageBody, log: WarnLogger): string {
|
|
53
53
|
if (page.bodyFormat === "markdown") return renderMarkdown(page.body);
|
|
@@ -63,18 +63,10 @@ export function createPageBodyRenderer(
|
|
|
63
63
|
return (page, log = defaultLog) => {
|
|
64
64
|
const key = cacheKeyOf(page);
|
|
65
65
|
const cached = cache.get(key);
|
|
66
|
-
if (cached !== undefined)
|
|
67
|
-
cache.delete(key);
|
|
68
|
-
cache.set(key, cached);
|
|
69
|
-
return cached;
|
|
70
|
-
}
|
|
66
|
+
if (cached !== undefined) return cached;
|
|
71
67
|
const html = renderUncached(page, log);
|
|
72
68
|
if (html.length > MAX_CACHED_BODY_LENGTH) return html;
|
|
73
69
|
cache.set(key, html);
|
|
74
|
-
if (cache.size > maxEntries) {
|
|
75
|
-
const oldest = cache.keys().next();
|
|
76
|
-
if (!oldest.done) cache.delete(oldest.value);
|
|
77
|
-
}
|
|
78
70
|
return html;
|
|
79
71
|
};
|
|
80
72
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type EntityDefinition,
|
|
21
21
|
type FeatureRegistrar,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
-
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.ts";
|
|
23
|
+
import { assertNoWhereRuleInOwnershipWrite, resolveFeatureAccess } from "../shared/index.ts";
|
|
24
24
|
import { createNoteEntryEntity, noteMentionEntity } from "./entity.ts";
|
|
25
25
|
import { createNoteEntryExecutor } from "./executor.ts";
|
|
26
26
|
import { createAddNoteHandler } from "./handlers/add-note.write.ts";
|
|
@@ -104,12 +104,6 @@ export type NotesHistoryFeatureOptions = {
|
|
|
104
104
|
readonly parents?: readonly string[];
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
-
function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
|
|
108
|
-
if (opts.access !== undefined) return opts.access;
|
|
109
|
-
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
110
|
-
return DEFAULT_NOTES_HISTORY_ACCESS;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
107
|
// Options wrapper. Without options returns the module-level singleton (no
|
|
114
108
|
// rebuild). access/roles/ownership build a fresh feature-definition.
|
|
115
109
|
export function createNotesHistoryFeature(
|
|
@@ -135,6 +129,11 @@ export function createNotesHistoryFeature(
|
|
|
135
129
|
);
|
|
136
130
|
}
|
|
137
131
|
return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
138
|
-
registerNotesHistory(
|
|
132
|
+
registerNotesHistory(
|
|
133
|
+
r,
|
|
134
|
+
resolveFeatureAccess(opts, DEFAULT_NOTES_HISTORY_ACCESS),
|
|
135
|
+
opts.ownership,
|
|
136
|
+
opts.parents,
|
|
137
|
+
),
|
|
139
138
|
);
|
|
140
139
|
}
|
|
@@ -4,6 +4,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { decryptStoredPii } from "../../shared/index.ts";
|
|
6
6
|
import { isExpiredAt } from "../expiry.ts";
|
|
7
|
+
import { parseScopeNames } from "../parse-scope-names.ts";
|
|
7
8
|
import { apiTokenTable } from "../schema/api-token.ts";
|
|
8
9
|
|
|
9
10
|
// `sort` arrives raw from the client's query string. selectMany's orderBy has
|
|
@@ -28,15 +29,6 @@ function tokenStatus(row: {
|
|
|
28
29
|
return "active";
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function parseScopeNames(raw: string): string[] {
|
|
32
|
-
try {
|
|
33
|
-
const parsed: unknown = JSON.parse(raw);
|
|
34
|
-
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
35
|
-
} catch {
|
|
36
|
-
return [];
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
32
|
// The caller's own tokens — metadata only, never the hash or plaintext.
|
|
41
33
|
// Includes revoked/expired rows so the UI can show history; `prefix` is the
|
|
42
34
|
// only fragment of the secret ever exposed.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { generateToken, PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { hashOpaqueTokenSync } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { PAT_PREFIX_DISPLAY_LENGTH } from "./web/constants.ts";
|
|
4
4
|
|
|
5
5
|
// A PAT is high-entropy (32 random bytes from generateToken). A single SHA-256
|
|
@@ -8,7 +8,7 @@ import { PAT_PREFIX_DISPLAY_LENGTH } from "./web/constants.ts";
|
|
|
8
8
|
// passwords). Only the hash is persisted — the plaintext exists once, in the
|
|
9
9
|
// create response.
|
|
10
10
|
export function hashPatToken(rawToken: string): string {
|
|
11
|
-
return
|
|
11
|
+
return hashOpaqueTokenSync(rawToken);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function mintPatToken(): { raw: string; hash: string; prefix: string } {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Drops non-string entries rather than rejecting the whole array.
|
|
2
|
+
export function parseScopeNames(raw: string): string[] {
|
|
3
|
+
try {
|
|
4
|
+
const parsed: unknown = JSON.parse(raw);
|
|
5
|
+
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
6
|
+
} catch {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -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
|
-
}
|
|
@@ -21,9 +21,7 @@ const features = [
|
|
|
21
21
|
const app = createKumikoApp({
|
|
22
22
|
features,
|
|
23
23
|
extraContext: ({ registry }) => ({
|
|
24
|
-
rendererFoundation: createRendererFoundationApi(
|
|
25
|
-
collectRendererPlugins(registry),
|
|
26
|
-
),
|
|
24
|
+
rendererFoundation: createRendererFoundationApi(collectRendererPlugins(registry)),
|
|
27
25
|
}),
|
|
28
26
|
});
|
|
29
27
|
```
|
|
@@ -39,7 +37,11 @@ async function sendMail(ctx, tenantId) {
|
|
|
39
37
|
const result = await renderer.render(
|
|
40
38
|
{
|
|
41
39
|
kind: "mail",
|
|
42
|
-
payload: {
|
|
40
|
+
payload: {
|
|
41
|
+
slug: "billing:notify:invoice-ready",
|
|
42
|
+
locale: "de",
|
|
43
|
+
variables: { name: "Frau Schmidt" },
|
|
44
|
+
},
|
|
43
45
|
},
|
|
44
46
|
{ db: ctx.db, registry: ctx.registry, tenantId, config: ctx.config },
|
|
45
47
|
);
|
|
@@ -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
|
}
|
|
@@ -11,7 +11,11 @@ const { pdfBytes, pageCount } = await renderTablePdf(
|
|
|
11
11
|
{
|
|
12
12
|
title: "Prüfprotokoll",
|
|
13
13
|
meta: [{ label: "Anlage", value: "Kessel 3" }],
|
|
14
|
-
columns: [
|
|
14
|
+
columns: [
|
|
15
|
+
{ header: "Datum" },
|
|
16
|
+
{ header: "Prüfer", weight: 2 },
|
|
17
|
+
{ header: "Kosten", align: "right" },
|
|
18
|
+
],
|
|
15
19
|
rows: [["03.02.2026", "Müller", "120,00 €"]],
|
|
16
20
|
footerText: "Kumiko GmbH",
|
|
17
21
|
pageLabel: "Seite {page} von {pages}",
|
|
@@ -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(
|