@cosmicdrift/kumiko-samples 0.364.0 → 0.365.1
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/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +13 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +7 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +12 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
- package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +65 -118
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
- package/packages/bundled-features/src/template-resolver/changes.json +81 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +6 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
- package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +9 -3
- package/packages/bundled-features/src/tier-engine/changes.json +12 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
2
|
import {
|
|
4
3
|
createSystemUser,
|
|
5
4
|
defineWriteHandler,
|
|
6
5
|
type HandlerContext,
|
|
7
6
|
parseTenantId,
|
|
8
|
-
type TenantId,
|
|
9
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
8
|
import {
|
|
11
9
|
InternalError,
|
|
@@ -18,13 +16,12 @@ import {
|
|
|
18
16
|
cancelPendingInvitation,
|
|
19
17
|
INVITATION_STATUS,
|
|
20
18
|
tenantInvitationsTable,
|
|
21
|
-
tenantMembershipsTable,
|
|
22
19
|
} from "../../tenant/index.js";
|
|
23
|
-
import { userTable } from "../../user/index.js";
|
|
24
20
|
import { WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
|
|
25
21
|
import { isOpenWaitlistStatus } from "../entity.js";
|
|
26
22
|
import {
|
|
27
23
|
adminAccess,
|
|
24
|
+
isTenantMember,
|
|
28
25
|
normalizeEmail,
|
|
29
26
|
platformActor,
|
|
30
27
|
waitlistDb,
|
|
@@ -34,18 +31,6 @@ import { WaitlistIdSchema } from "../payloads.js";
|
|
|
34
31
|
|
|
35
32
|
export type WaitlistRejectData = { readonly kind: "rejected"; readonly id: string };
|
|
36
33
|
|
|
37
|
-
// An admin re-invite can flip an accepted invitation back to pending, so the
|
|
38
|
-
// invitation status alone cannot prove the person holds no access.
|
|
39
|
-
async function isTenantMember(db: TenantDb, email: string, tenantId: TenantId): Promise<boolean> {
|
|
40
|
-
const user = await fetchOne<{ id: string }>(db, userTable, {
|
|
41
|
-
email: normalizeEmail(email),
|
|
42
|
-
isDeleted: false,
|
|
43
|
-
});
|
|
44
|
-
if (!user) return false;
|
|
45
|
-
const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
|
|
46
|
-
return membership !== undefined && membership !== null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
34
|
// An invited entry already has a live invitation link; rejecting must kill it,
|
|
50
35
|
// otherwise the rejected person can still sign up. Once accepted they hold
|
|
51
36
|
// access, which only tenant member removal may revoke.
|
|
@@ -36,10 +36,13 @@ const SUBMIT_ROLES = access.roles(
|
|
|
36
36
|
"TenantAdmin",
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
+
const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
|
|
40
|
+
|
|
39
41
|
export type WaitlistSubmitData = { readonly kind: "submitted" };
|
|
40
42
|
|
|
41
|
-
// Identical for new, duplicate and honeypot submits
|
|
42
|
-
//
|
|
43
|
+
// Identical body for new, duplicate and honeypot submits so the response does not
|
|
44
|
+
// state whether the email is already on the list. Timing still differs: only the
|
|
45
|
+
// new-entry path writes and sends mail.
|
|
43
46
|
const SUBMITTED: WriteResult<WaitlistSubmitData> = {
|
|
44
47
|
isSuccess: true,
|
|
45
48
|
data: { kind: "submitted" },
|
|
@@ -60,13 +63,32 @@ function isHoneypotFilled(payload: WaitlistSubmitInput): boolean {
|
|
|
60
63
|
|
|
61
64
|
async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise<boolean> {
|
|
62
65
|
const found = await waitlistExecutor.list(
|
|
63
|
-
{
|
|
66
|
+
{
|
|
67
|
+
filter: { field: "email", op: "eq", value: email },
|
|
68
|
+
filters: [{ field: "status", op: "in", value: OPEN_STATUSES }],
|
|
69
|
+
limit: 1,
|
|
70
|
+
},
|
|
64
71
|
platformActor(),
|
|
65
72
|
waitlistDb(ctx),
|
|
66
73
|
);
|
|
67
|
-
return found.rows.
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
return found.rows.length > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The resolver usually reads config; run it outside the handler transaction's
|
|
78
|
+
// critical section so a failing statement cannot abort the transaction that holds
|
|
79
|
+
// the entry, and a slow resolver does not extend the per-email advisory lock.
|
|
80
|
+
async function resolveAdminRecipient(
|
|
81
|
+
opts: WaitlistOptions,
|
|
82
|
+
ctx: HandlerContext,
|
|
83
|
+
): Promise<string | null> {
|
|
84
|
+
try {
|
|
85
|
+
return (await opts.notifyRecipient?.(ctx)) || null;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
async function sendBestEffort(
|
|
@@ -87,14 +109,16 @@ async function sendBestEffort(
|
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
export function createSubmitHandler(opts: WaitlistOptions) {
|
|
112
|
+
const emailRateLimits = opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS;
|
|
90
113
|
return defineWriteHandler<"submit", typeof WaitlistSubmitSchema, WaitlistSubmitData>({
|
|
91
114
|
name: "submit",
|
|
92
115
|
schema: WaitlistSubmitSchema,
|
|
93
116
|
access: { roles: SUBMIT_ROLES, personalData: "public-intake" },
|
|
94
117
|
rateLimit: opts.rateLimit ?? DEFAULT_SUBMIT_RATE_LIMIT,
|
|
95
|
-
|
|
118
|
+
// An empty array means "disabled"; boot validation rejects an empty additionalRateLimits.
|
|
119
|
+
...(emailRateLimits.length > 0 && { additionalRateLimits: emailRateLimits }),
|
|
96
120
|
description:
|
|
97
|
-
"Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail.
|
|
121
|
+
"Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Reports success whether the address is new or already on the list; the response body is identical, response timing can still differ.",
|
|
98
122
|
escapeHatch: { grants: ["unsafeRaw"], reason: SUBMIT_LOCK_REASON },
|
|
99
123
|
handler: async (event, ctx) => {
|
|
100
124
|
const payload = event.payload;
|
|
@@ -106,6 +130,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
|
|
|
106
130
|
message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
|
|
107
131
|
});
|
|
108
132
|
}
|
|
133
|
+
const adminRecipient = await resolveAdminRecipient(opts, ctx);
|
|
109
134
|
await acquireNamespacedAdvisoryLock(
|
|
110
135
|
ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
|
|
111
136
|
SUBMIT_LOCK_NAMESPACE,
|
|
@@ -140,7 +165,6 @@ export function createSubmitHandler(opts: WaitlistOptions) {
|
|
|
140
165
|
);
|
|
141
166
|
|
|
142
167
|
await sendBestEffort(ctx, "admin notice", async (notify) => {
|
|
143
|
-
const adminRecipient = await opts.notifyRecipient?.(ctx);
|
|
144
168
|
// skip: no admin recipient configured
|
|
145
169
|
if (!adminRecipient) return;
|
|
146
170
|
await notify(WAITLIST_NOTIFICATION_TYPES.adminNotice, {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
type LocalizedString = { readonly en: string; readonly de: string };
|
|
2
2
|
|
|
3
3
|
export const WAITLIST_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"waitlist:feature.label": { en: "Waitlist", de: "Warteliste" },
|
|
5
|
+
"waitlist:notify.confirmation.label": {
|
|
6
|
+
en: "Waitlist confirmation",
|
|
7
|
+
de: "Bestätigung der Warteliste",
|
|
8
|
+
},
|
|
9
|
+
"waitlist:notify.admin-notice.label": {
|
|
10
|
+
en: "New waitlist entry",
|
|
11
|
+
de: "Neuer Eintrag auf der Warteliste",
|
|
12
|
+
},
|
|
4
13
|
"screen:waitlist-list.title": { en: "Waitlist", de: "Warteliste" },
|
|
5
14
|
"waitlist.nav.waitlist": { en: "Waitlist", de: "Warteliste" },
|
|
6
15
|
"waitlist:entity:waitlistEntry:field:name": { en: "Name", de: "Name" },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
@@ -5,8 +6,11 @@ import {
|
|
|
5
6
|
type HandlerContext,
|
|
6
7
|
type SessionUser,
|
|
7
8
|
SYSTEM_TENANT_ID,
|
|
9
|
+
type TenantId,
|
|
8
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
11
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { tenantMembershipsTable } from "../tenant/index.js";
|
|
13
|
+
import { userTable } from "../user/index.js";
|
|
10
14
|
import { waitlistEntryEntity, waitlistEntryTable } from "./entity.js";
|
|
11
15
|
|
|
12
16
|
export const WAITLIST_CROSS_TENANT_REASON = "waitlist is platform-wide (systemScope)";
|
|
@@ -34,3 +38,19 @@ export function waitlistDb(ctx: HandlerContext): TenantDb {
|
|
|
34
38
|
export function platformActor(): SessionUser {
|
|
35
39
|
return createSystemUser(SYSTEM_TENANT_ID);
|
|
36
40
|
}
|
|
41
|
+
|
|
42
|
+
// An admin re-invite can flip an accepted invitation back to pending, so the
|
|
43
|
+
// invitation status alone cannot prove the person holds no access.
|
|
44
|
+
export async function isTenantMember(
|
|
45
|
+
db: TenantDb,
|
|
46
|
+
email: string,
|
|
47
|
+
tenantId: TenantId,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const user = await fetchOne<{ id: string }>(db, userTable, {
|
|
50
|
+
email: normalizeEmail(email),
|
|
51
|
+
isDeleted: false,
|
|
52
|
+
});
|
|
53
|
+
if (!user) return false;
|
|
54
|
+
const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
|
|
55
|
+
return membership !== undefined && membership !== null;
|
|
56
|
+
}
|
|
@@ -32,7 +32,7 @@ import { workflowRunPendingTableMeta } from "./tables.js";
|
|
|
32
32
|
|
|
33
33
|
// Kebab-case matching the "./workflow-runner" export subpath — every other
|
|
34
34
|
// bundled feature's runtime name follows this convention, and
|
|
35
|
-
//
|
|
35
|
+
// use-all-bundled's check-coverage matches exports to mounted features by it.
|
|
36
36
|
const FEATURE_NAME = "workflow-runner";
|
|
37
37
|
|
|
38
38
|
export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
@@ -35,7 +35,20 @@ export const APP_FEATURES = [
|
|
|
35
35
|
createDocumentDesignerFeature(),
|
|
36
36
|
// Mounted individually: composeRendererStack() would add a second template-resolver.
|
|
37
37
|
createRendererFoundationFeature(),
|
|
38
|
-
createRendererMailHtmlFeature(
|
|
38
|
+
createRendererMailHtmlFeature({
|
|
39
|
+
footer: ({ locale }) =>
|
|
40
|
+
locale.startsWith("de")
|
|
41
|
+
? {
|
|
42
|
+
text: "Gesendet über das Nordlicht Mieterportal",
|
|
43
|
+
imprint: { label: "Impressum", url: "https://nordlicht-immobilien.de/impressum" },
|
|
44
|
+
privacy: { label: "Datenschutz", url: "https://nordlicht-immobilien.de/datenschutz" },
|
|
45
|
+
}
|
|
46
|
+
: {
|
|
47
|
+
text: "Sent via the Nordlicht tenant portal",
|
|
48
|
+
imprint: { label: "Imprint", url: "https://nordlicht-immobilien.de/imprint" },
|
|
49
|
+
privacy: { label: "Privacy", url: "https://nordlicht-immobilien.de/privacy" },
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
39
52
|
// The PDF preview of the template editor prints through the puppeteer-service
|
|
40
53
|
// (PUPPETEER_SERVICE_URL / PUPPETEER_AUTH_TOKEN); the e2e config starts one.
|
|
41
54
|
createRendererPuppeteerClientFeature(),
|
|
@@ -33,24 +33,14 @@ Exits 0 on success; any feature-wiring fault throws and exits 1.
|
|
|
33
33
|
|
|
34
34
|
## Coverage status
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- `auth-email-password` — auto-mounted via `composeFeatures(authOptions)` (no need to list)
|
|
39
|
-
- `files-provider-s3` — utility helpers (`createS3Provider`), kein `defineFeature`
|
|
40
|
-
- `foundation-shared` — pure utilities (`requireDefined`, `requireNonEmpty`), kein `defineFeature`
|
|
41
|
-
|
|
42
|
-
The 3 remaining are utilities or auto-mounted — they cannot be added to `APP_FEATURES` by design.
|
|
36
|
+
Every bundled-feature export is mounted except the held-back ones in `EXPECTED_HELD_BACK` (`scripts/check-coverage.ts`): utilities without `defineFeature` and scaffold-only features. They cannot be added to `APP_FEATURES` by design.
|
|
43
37
|
|
|
44
38
|
## Maintenance
|
|
45
39
|
|
|
46
40
|
When you add a new feature-export to `@cosmicdrift/kumiko-bundled-features` you MUST either:
|
|
47
41
|
|
|
48
42
|
1. Mount it in `src/run-config.ts`. `kumiko/schema.ts` picks up entities via
|
|
49
|
-
`composeFeatures(APP_FEATURES)
|
|
50
|
-
```sh
|
|
51
|
-
cd samples/apps/use-all-bundled
|
|
52
|
-
bun ../../bin/kumiko.ts schema generate feature-coverage
|
|
53
|
-
```
|
|
43
|
+
`composeFeatures(APP_FEATURES)`; run `kumiko-schema generate` if the schema changed.
|
|
54
44
|
2. Or, if the feature is not yet smoke-ready, add it to `EXPECTED_HELD_BACK` in `scripts/check-coverage.ts` with a one-line reason.
|
|
55
45
|
|
|
56
|
-
CI fails otherwise via `Coverage lint
|
|
46
|
+
CI fails otherwise via `Coverage lint`.
|
|
@@ -2122,7 +2122,7 @@
|
|
|
2122
2122
|
},
|
|
2123
2123
|
{
|
|
2124
2124
|
"name": "renderer-mail-html",
|
|
2125
|
-
"description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, in-code
|
|
2125
|
+
"description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.",
|
|
2126
2126
|
"toggleableDefault": null,
|
|
2127
2127
|
"requires": [
|
|
2128
2128
|
"renderer-foundation",
|
|
@@ -2170,7 +2170,7 @@
|
|
|
2170
2170
|
},
|
|
2171
2171
|
{
|
|
2172
2172
|
"name": "renderer-puppeteer-client",
|
|
2173
|
-
"description": "A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content
|
|
2173
|
+
"description": "A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.",
|
|
2174
2174
|
"toggleableDefault": null,
|
|
2175
2175
|
"requires": [
|
|
2176
2176
|
"renderer-foundation"
|
|
@@ -2391,7 +2391,7 @@
|
|
|
2391
2391
|
},
|
|
2392
2392
|
{
|
|
2393
2393
|
"name": "step-dispatcher",
|
|
2394
|
-
"description": "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits.
|
|
2394
|
+
"description": "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. An `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event enqueues one `step-dispatcher:job:deliver` BullMQ job per dispatch (Redis carries only the stream id and tenant, never the request payload). The job performs the actual HTTP or mail delivery and appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream, so the outcome is recorded in the event log without a separate status table. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome is recorded after the final attempt and the payload key is erased only then. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded immediately. At most 2 deliveries per tenant run at once (`maxPerTenant` with `onLimit: \"defer\"`), so a slow receiver of one tenant does not delay other tenants. Requires a job runner (Redis) and a worker consuming the job's default `worker` lane; without a job runner the projection fails loudly. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across retries and redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.",
|
|
2395
2395
|
"toggleableDefault": null,
|
|
2396
2396
|
"requires": [
|
|
2397
2397
|
"secrets"
|
|
@@ -3209,7 +3209,7 @@
|
|
|
3209
3209
|
},
|
|
3210
3210
|
{
|
|
3211
3211
|
"name": "waitlist",
|
|
3212
|
-
"description": "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response
|
|
3212
|
+
"description": "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
|
|
3213
3213
|
"toggleableDefault": null,
|
|
3214
3214
|
"requires": [
|
|
3215
3215
|
"tenant",
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// biome-ignore-all lint/suspicious/noConsole: CLI-Script, console ist Feature.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Coverage lint. Diffs die Top-Level-Exports von bundled-features gegen die Features, die
|
|
5
|
+
// use-all-bundled mountet (FEATURES aus kumiko/schema.ts: APP_FEATURES plus die von
|
|
6
|
+
// composeFeatures ergänzten Auth-/Signup-Features). Fängt drei Drifts:
|
|
7
7
|
//
|
|
8
|
-
// 1. Ein
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// → stale registry
|
|
12
|
-
//
|
|
13
|
-
// Held-back features für M0.1 (subscription-stripe, channel-email, …)
|
|
14
|
-
// stehen in EXPECTED_HELD_BACK. Sobald sie mit stub-options gemountet
|
|
15
|
-
// werden, müssen sie aus dieser Liste raus.
|
|
8
|
+
// 1. Ein Export, der weder gemountet ist noch in EXPECTED_HELD_BACK steht
|
|
9
|
+
// 2. Ein EXPECTED_HELD_BACK-Eintrag, der kein Export mehr ist
|
|
10
|
+
// 3. Ein EXPECTED_HELD_BACK-Eintrag, der inzwischen gemountet ist (stale)
|
|
16
11
|
//
|
|
17
12
|
// Usage:
|
|
18
13
|
// bun samples/apps/use-all-bundled/scripts/check-coverage.ts
|
|
@@ -20,6 +15,7 @@
|
|
|
20
15
|
|
|
21
16
|
import { readFileSync } from "node:fs";
|
|
22
17
|
import { resolve } from "node:path";
|
|
18
|
+
import { FEATURES } from "../kumiko/schema";
|
|
23
19
|
|
|
24
20
|
const ROOT = resolve(import.meta.dir, "..");
|
|
25
21
|
const FRAMEWORK_ROOT = resolve(ROOT, "..", "..", "..");
|
|
@@ -27,33 +23,13 @@ const FRAMEWORK_ROOT = resolve(ROOT, "..", "..", "..");
|
|
|
27
23
|
// Held-back exports: existieren in bundled-features, sind aber nicht
|
|
28
24
|
// als runtime-feature in use-all-bundled mountbar.
|
|
29
25
|
//
|
|
30
|
-
//
|
|
31
|
-
// -
|
|
32
|
-
// sind pure helpers).
|
|
33
|
-
// - auto-mounted: kommt via composeFeatures(includeBundled:true) automatisch
|
|
34
|
-
// (auth-email-password) — wäre doppelt-mount wenn explizit gelistet.
|
|
26
|
+
// Nur Utilities ohne defineFeature (foundation-shared, files-provider-s3 sind pure helpers)
|
|
27
|
+
// und scaffold-only Features. Gemountete Features gehören nicht hierher.
|
|
35
28
|
const EXPECTED_HELD_BACK = new Set([
|
|
36
|
-
"auth-email-password", // auto-mounted via composeFeatures(authOptions)
|
|
37
|
-
"agent-tools", // utility (buildToolCatalog: Registry -> ToolDefinition[]), kein defineFeature
|
|
38
29
|
"files-provider-s3", // utility (createS3Provider helpers), kein defineFeature
|
|
39
30
|
"foundation-shared", // utilities (requireDefined/requireNonEmpty), kein feature
|
|
40
31
|
"page-render", // shared HTML render/cache helpers (legal/managed-pages), kein defineFeature
|
|
41
32
|
"presets", // feature-bundle helpers (dsgvoSelfServiceFeatures, securityBaselineFeatures), kein defineFeature
|
|
42
|
-
"auth-foundation", // scaffold-only (#1368): bootCheck wirft ohne gemountete auth-provider-*, die es noch nicht gibt
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
// Registry-only: mounted features that are NOT top-level package exports
|
|
46
|
-
// (companion features living under another export path). schema-check still
|
|
47
|
-
// needs them in FEATURE_IMPORT_REGISTRY; coverage must not flag them stale.
|
|
48
|
-
const EXPECTED_REGISTRY_ONLY = new Set([
|
|
49
|
-
"auth-self-registration", // from ./auth-email-password, auto-mounted with signup (#1521)
|
|
50
|
-
"collection-labels", // app-local (src/app/), nav labels for this app's content collections
|
|
51
|
-
"cap-overview-labels", // app-local (src/app/), labels for this app's example cap-overview caps
|
|
52
|
-
"metrics-system", // from ./metrics, companion of createMetricsFeature (platform-wide scope)
|
|
53
|
-
"locale-de", // @cosmicdrift/kumiko-locale-de, not a bundled-features export
|
|
54
|
-
"renderer-mail-html", // @cosmicdrift/kumiko-renderer-mail-html, auto-mounted with auth by composeFeatures
|
|
55
|
-
"renderer-puppeteer-client", // @cosmicdrift/kumiko-renderer-puppeteer-client, not a bundled-features export
|
|
56
|
-
"document-designer", // @cosmicdrift/kumiko-document-designer, not a bundled-features export
|
|
57
33
|
]);
|
|
58
34
|
|
|
59
35
|
// Sub-paths in bundled-features's package.json exports (./tenant/seeding,
|
|
@@ -87,120 +63,60 @@ function readBundledExports(): Set<string> {
|
|
|
87
63
|
return out;
|
|
88
64
|
}
|
|
89
65
|
|
|
90
|
-
function extractRegistryBlock(src: string): string {
|
|
91
|
-
// Anchor auf den FEATURE_IMPORT_REGISTRY-Object-Body. Ohne diesen Anchor
|
|
92
|
-
// würde die kind-Regex jedes `<ident>: { kind: "factory"|"named" }` im File
|
|
93
|
-
// matchen (z.B. eine künftige Hilfsvariable) → false positive/negative.
|
|
94
|
-
const startMatch = /FEATURE_IMPORT_REGISTRY[^=]*=\s*\{/.exec(src);
|
|
95
|
-
if (!startMatch) {
|
|
96
|
-
throw new Error("check-coverage: FEATURE_IMPORT_REGISTRY block not found in generate.ts");
|
|
97
|
-
}
|
|
98
|
-
const bodyStart = startMatch.index + startMatch[0].length;
|
|
99
|
-
// Top-level-Close ist das erste `};` am Zeilenanfang nach bodyStart.
|
|
100
|
-
const closeMatch = /\n};/.exec(src.slice(bodyStart));
|
|
101
|
-
if (!closeMatch) {
|
|
102
|
-
throw new Error("check-coverage: FEATURE_IMPORT_REGISTRY closing `};` not found");
|
|
103
|
-
}
|
|
104
|
-
return src.slice(bodyStart, bodyStart + closeMatch.index);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function readRegistryFeatures(): Set<string> {
|
|
108
|
-
const generateSrc = readFileSync(resolve(ROOT, "schema", "generate.ts"), "utf-8");
|
|
109
|
-
const registryBlock = extractRegistryBlock(generateSrc);
|
|
110
|
-
// Match object-keys mit Discriminator `kind: "factory" | "named"`.
|
|
111
|
-
// Property-Keys können quoted ("billing-foundation") oder unquoted
|
|
112
|
-
// (config, user) sein — beides ist gültig wenn kebab-segment-free.
|
|
113
|
-
const re = /(?:"([a-z][a-z0-9-]*)"|([a-z][a-z0-9]*)):\s*\{\s*kind:\s*"(factory|named)"/g;
|
|
114
|
-
const out = new Set<string>();
|
|
115
|
-
for (const m of registryBlock.matchAll(re)) {
|
|
116
|
-
const name = m[1] ?? m[2];
|
|
117
|
-
if (name) out.add(name);
|
|
118
|
-
}
|
|
119
|
-
return out;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
66
|
const exports = readBundledExports();
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const unreferenced: string[] = [];
|
|
126
|
-
const stale: string[] = [];
|
|
127
|
-
// Held-back-Einträge, die kein bundled-Export mehr sind → stale Konfig.
|
|
128
|
-
// Ohne diese Prüfung hätte der Drift-Guard in seiner eigenen Konfig einen
|
|
129
|
-
// Blind-Spot: ein umbenanntes/entferntes Feature bliebe still in der Liste.
|
|
130
|
-
const staleHeldBack: string[] = [];
|
|
131
|
-
|
|
132
|
-
for (const name of exports) {
|
|
133
|
-
if (registry.has(name)) continue;
|
|
134
|
-
if (EXPECTED_HELD_BACK.has(name)) continue;
|
|
135
|
-
unreferenced.push(name);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
for (const name of registry) {
|
|
139
|
-
if (!exports.has(name) && !EXPECTED_REGISTRY_ONLY.has(name)) stale.push(name);
|
|
140
|
-
}
|
|
67
|
+
const mounted = new Set<string>(FEATURES.map((feature) => feature.name));
|
|
141
68
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
69
|
+
const unreferenced = [...exports].filter(
|
|
70
|
+
(name) => !mounted.has(name) && !EXPECTED_HELD_BACK.has(name),
|
|
71
|
+
);
|
|
72
|
+
// Ohne diese Prüfungen hätte der Guard in seiner eigenen Konfig einen Blind-Spot: ein
|
|
73
|
+
// umbenanntes oder entferntes Feature bliebe still in der Liste, ein gemountetes ebenso.
|
|
74
|
+
const staleHeldBack = [...EXPECTED_HELD_BACK].filter((name) => !exports.has(name));
|
|
75
|
+
const mountedHeldBack = [...EXPECTED_HELD_BACK].filter((name) => mounted.has(name));
|
|
145
76
|
|
|
146
77
|
let ok = true;
|
|
147
78
|
|
|
148
79
|
if (unreferenced.length > 0) {
|
|
149
80
|
console.error(
|
|
150
|
-
`\n❌ ${unreferenced.length} bundled-feature export(s)
|
|
81
|
+
`\n❌ ${unreferenced.length} bundled-feature export(s) nicht in use-all-bundled gemountet:`,
|
|
151
82
|
);
|
|
152
83
|
for (const name of unreferenced.sort()) {
|
|
153
84
|
console.error(` - ${name}`);
|
|
154
85
|
}
|
|
155
86
|
console.error(
|
|
156
|
-
"\n Action: in src/run-config.ts
|
|
157
|
-
);
|
|
158
|
-
console.error(
|
|
159
|
-
" oder (wenn mount-options noch fehlen) zu EXPECTED_HELD_BACK in dieser Datei hinzufügen.",
|
|
87
|
+
"\n Action: in src/run-config.ts mounten, oder (wenn mount-options noch fehlen) zu EXPECTED_HELD_BACK in dieser Datei hinzufügen.",
|
|
160
88
|
);
|
|
161
89
|
ok = false;
|
|
162
90
|
}
|
|
163
91
|
|
|
164
|
-
if (
|
|
92
|
+
if (staleHeldBack.length > 0) {
|
|
165
93
|
console.error(
|
|
166
|
-
`\n❌ ${
|
|
94
|
+
`\n❌ ${staleHeldBack.length} EXPECTED_HELD_BACK entry/entries ohne Match in bundled-features exports:`,
|
|
167
95
|
);
|
|
168
|
-
for (const name of
|
|
96
|
+
for (const name of staleHeldBack.sort()) {
|
|
169
97
|
console.error(` - ${name}`);
|
|
170
98
|
}
|
|
171
99
|
console.error(
|
|
172
|
-
"\n Action: aus
|
|
100
|
+
"\n Action: aus EXPECTED_HELD_BACK in dieser Datei entfernen (Feature wurde umbenannt/entfernt).",
|
|
173
101
|
);
|
|
174
102
|
ok = false;
|
|
175
103
|
}
|
|
176
104
|
|
|
177
|
-
if (
|
|
105
|
+
if (mountedHeldBack.length > 0) {
|
|
178
106
|
console.error(
|
|
179
|
-
`\n❌ ${
|
|
107
|
+
`\n❌ ${mountedHeldBack.length} EXPECTED_HELD_BACK entry/entries sind gemountet (stale):`,
|
|
180
108
|
);
|
|
181
|
-
for (const name of
|
|
109
|
+
for (const name of mountedHeldBack.sort()) {
|
|
182
110
|
console.error(` - ${name}`);
|
|
183
111
|
}
|
|
184
|
-
console.error(
|
|
185
|
-
"\n Action: aus EXPECTED_HELD_BACK in dieser Datei entfernen (Feature wurde umbenannt/entfernt).",
|
|
186
|
-
);
|
|
112
|
+
console.error("\n Action: aus EXPECTED_HELD_BACK in dieser Datei entfernen.");
|
|
187
113
|
ok = false;
|
|
188
114
|
}
|
|
189
115
|
|
|
190
|
-
if (ok)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
for (const name of registry) {
|
|
198
|
-
if (exports.has(name) && !EXPECTED_HELD_BACK.has(name)) coveredCount++;
|
|
199
|
-
}
|
|
200
|
-
console.log(
|
|
201
|
-
`✓ Coverage: ${coveredCount}/${mountable} mountable bundled-features im Registry, ${EXPECTED_HELD_BACK.size} held-back (M0.1).`,
|
|
202
|
-
);
|
|
203
|
-
process.exit(0);
|
|
204
|
-
} else {
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
116
|
+
if (!ok) process.exit(1);
|
|
117
|
+
|
|
118
|
+
const mountable = exports.size - EXPECTED_HELD_BACK.size;
|
|
119
|
+
const mountedExports = [...exports].filter((name) => mounted.has(name)).length;
|
|
120
|
+
console.log(
|
|
121
|
+
`✓ Coverage: ${mountedExports}/${mountable} mountable bundled-features gemountet, ${EXPECTED_HELD_BACK.size} held-back.`,
|
|
122
|
+
);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// proving the "extend any object" promise on real, bootable screens.
|
|
7
7
|
//
|
|
8
8
|
// Deliberately NOT in src/run-config.ts APP_FEATURES: that set is bundled-only
|
|
9
|
-
// (
|
|
9
|
+
// (check-coverage compares it against the bundled-features exports). Like
|
|
10
10
|
// appScreensFeature, this app-owned feature is mounted only in the dev server
|
|
11
11
|
// (src/app/dev-app-options.ts), where runDevApp dev-pushes its table into the ephemeral
|
|
12
12
|
// DB. Nothing here ships to prod / the schema pipeline.
|
|
@@ -10,7 +10,7 @@ The production wiring shape: mount `createAuthMfaFeature()` next to `createAuthE
|
|
|
10
10
|
|
|
11
11
|
1. `createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })` builds the feature; `mfaStatusCheckerFromFeature(mfaFeature)` reads its login-status callback back off.
|
|
12
12
|
2. That callback goes into `createAuthEmailPasswordFeature({ mfaStatusChecker: ... })` — `login.write.ts` calls it after the password check to decide whether to mint a JWT or a challenge.
|
|
13
|
-
3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify } })` mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
|
|
13
|
+
3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify, switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate } })` (the gate is required with `mfaVerifyHandler`, so a tenant switch asks for the second factor too) mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
|
|
14
14
|
4. If `sessions` is mounted too, `bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)` wires "enable/disable/regenerate signs out every other session."
|
|
15
15
|
|
|
16
16
|
## What to copy into your app
|
|
@@ -43,6 +43,7 @@ const server = buildServer({
|
|
|
43
43
|
membershipQuery: "tenant:query:memberships",
|
|
44
44
|
loginHandler: "auth-email-password:[REDACTED:API key param]",
|
|
45
45
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
46
|
+
switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
46
47
|
sessionCreator: sessionCallbacks.sessionCreator,
|
|
47
48
|
sessionRevoker: sessionCallbacks.sessionRevoker,
|
|
48
49
|
sessionChecker: sessionCallbacks.sessionChecker,
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
// membershipQuery: "tenant:query:memberships",
|
|
38
38
|
// loginHandler: "auth-email-password:[REDACTED:API key param]",
|
|
39
39
|
// mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
40
|
+
// switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
40
41
|
// sessionCreator: sessionCallbacks.sessionCreator,
|
|
41
42
|
// sessionRevoker: sessionCallbacks.sessionRevoker,
|
|
42
43
|
// sessionChecker: sessionCallbacks.sessionChecker,
|
|
@@ -22,9 +22,10 @@ logic is yours.
|
|
|
22
22
|
- **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
|
|
23
23
|
bypasses the version check because the operation is admin-driven and
|
|
24
24
|
resetting twice is semantically the same as resetting once.
|
|
25
|
-
- **Custom queries** — `counter:active`
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
- **Custom queries** — `counter:active` passes a server-side `filter`
|
|
26
|
+
(`eq`/`ne`/`lt`/`gt`/`lte`/`gte`/`in`) and `totalCount` to the executor's
|
|
27
|
+
list. Never filter a page in TypeScript: list is paginated, so rows after
|
|
28
|
+
the first page would be missed.
|
|
28
29
|
- **`failNotFound` helper** — typed error response for the 404 case
|
|
29
30
|
without a hand-built `KumikoError` subclass.
|
|
30
31
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Custom Handlers Sample
|
|
2
|
-
// Shows: writeHandler with business logic, queryHandler with
|
|
2
|
+
// Shows: writeHandler with business logic, queryHandler with a server-side filter,
|
|
3
3
|
// handler that modifies payload before DB write.
|
|
4
4
|
|
|
5
5
|
import {
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
|
|
15
|
+
// detail() returns an untyped row; parse the two fields the increment needs once.
|
|
16
|
+
const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
|
|
17
|
+
|
|
15
18
|
export const counterEntity = createEntity({
|
|
16
19
|
table: "read_sample_counters",
|
|
17
20
|
fields: {
|
|
@@ -48,13 +51,14 @@ export const counterFeature = defineFeature("counters", (r) => {
|
|
|
48
51
|
return failNotFound("counter", event.payload.id);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
const
|
|
54
|
+
const { count, version } = counterVersionRowSchema.parse(current);
|
|
55
|
+
const newCount = count + event.payload.amount;
|
|
52
56
|
// Read-modify-write on counter: use the version we just read so two
|
|
53
57
|
// concurrent increments don't clobber each other's changes.
|
|
54
58
|
return counterExecutor.update(
|
|
55
59
|
{
|
|
56
60
|
id: event.payload.id,
|
|
57
|
-
version
|
|
61
|
+
version,
|
|
58
62
|
changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
|
|
59
63
|
},
|
|
60
64
|
event.user,
|
|
@@ -80,17 +84,22 @@ export const counterFeature = defineFeature("counters", (r) => {
|
|
|
80
84
|
{ access: { roles: ["Admin"] } },
|
|
81
85
|
);
|
|
82
86
|
|
|
83
|
-
// Custom query: only counters above a threshold
|
|
87
|
+
// Custom query: only counters above a threshold. list() returns one page
|
|
88
|
+
// (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
|
|
89
|
+
// page 1; filter in the query instead. totalCount counts over the whole WHERE.
|
|
84
90
|
r.queryHandler(
|
|
85
91
|
"counter:active",
|
|
86
|
-
z.object({ minCount: z.number().default(1) }),
|
|
87
|
-
async (query, ctx) =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
|
|
93
|
+
async (query, ctx) =>
|
|
94
|
+
counterExecutor.list(
|
|
95
|
+
{
|
|
96
|
+
filter: { field: "count", op: "gte", value: query.payload.minCount },
|
|
97
|
+
totalCount: true,
|
|
98
|
+
cursor: query.payload.cursor,
|
|
99
|
+
},
|
|
100
|
+
query.user,
|
|
101
|
+
ctx.db,
|
|
102
|
+
),
|
|
94
103
|
{
|
|
95
104
|
access: {
|
|
96
105
|
openToAll: {
|