@cosmicdrift/kumiko-samples 0.351.0 → 0.353.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 +2 -2
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +6 -1
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/ledger/entity.ts +4 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/tenant/changes.json +7 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +13 -1
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.353.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.353.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"nodemailer": "^10.0.13",
|
|
165
165
|
"qrcode": "^1.5.4",
|
|
166
166
|
"react": "^19.2.6",
|
|
167
|
-
"sharp": "^0.35.
|
|
167
|
+
"sharp": "^0.35.5",
|
|
168
168
|
"stripe": "^22.6.2",
|
|
169
169
|
"tailwind-merge": "^3.6.0",
|
|
170
170
|
"temporal-polyfill": "^0.3.2",
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
21
|
import * as z from "zod";
|
|
22
|
+
import { recordManifestDenyQns } from "./manifest-deny-qns.js";
|
|
22
23
|
import type {
|
|
23
24
|
AgentManifest,
|
|
24
25
|
AgentManifestEntity,
|
|
@@ -334,6 +335,16 @@ function detailIdParam(screen: ScreenDefinition): string {
|
|
|
334
335
|
return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
|
|
335
336
|
}
|
|
336
337
|
|
|
338
|
+
function screenTitles(
|
|
339
|
+
translations: TranslationKeys,
|
|
340
|
+
screenId: string,
|
|
341
|
+
shortId: string,
|
|
342
|
+
): AgentManifestScreen["titles"] {
|
|
343
|
+
return entryAsLabels(
|
|
344
|
+
translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {},
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
337
348
|
function buildScreens(
|
|
338
349
|
screens: ReadonlyMap<string, ScreenDefinition>,
|
|
339
350
|
navs: ReadonlyMap<string, NavDefinition>,
|
|
@@ -372,7 +383,7 @@ function buildScreens(
|
|
|
372
383
|
result.push({
|
|
373
384
|
id: screen.id,
|
|
374
385
|
type: screen.type,
|
|
375
|
-
titles:
|
|
386
|
+
titles: screenTitles(translations, screen.id, shortId),
|
|
376
387
|
// `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
|
|
377
388
|
// double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
|
|
378
389
|
// `labelsForSuffix` does for entity/field labels, else fall back to the
|
|
@@ -444,7 +455,7 @@ export function buildAgentManifest(
|
|
|
444
455
|
);
|
|
445
456
|
const workspaces = buildWorkspaces(workspaceMap, translations, roles);
|
|
446
457
|
|
|
447
|
-
|
|
458
|
+
const manifest: AgentManifest = {
|
|
448
459
|
builtForRoles: [...roles],
|
|
449
460
|
features: sortedByKey(features, (f) => f.name),
|
|
450
461
|
entities: sortedByKey(entities, (e) => e.name),
|
|
@@ -457,4 +468,6 @@ export function buildAgentManifest(
|
|
|
457
468
|
...(options.currency && { currency: options.currency }),
|
|
458
469
|
},
|
|
459
470
|
};
|
|
471
|
+
recordManifestDenyQns(manifest, new Set(options.denyQns ?? []));
|
|
472
|
+
return manifest;
|
|
460
473
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "buildToolCatalog throws when the manifest was built with denyQns the catalog options lack",
|
|
6
|
+
"migration": "Pass the same denyQns list to buildAgentManifest and buildToolCatalog."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.353.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Agent manifest screen titles resolve per feature instead of by mount order",
|
|
12
|
+
"detail": "Agent manifest screen titles no longer depend on feature mount order\nTwo features with a screen of the same short id used to get the title of whichever feature was mounted first. The manifest now looks up the screen's own feature-prefixed title key first and falls back to the unprefixed key."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.338.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentManifest } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// The deny list is deliberately not a manifest field (the manifest is prompt
|
|
4
|
+
// payload), so the catalog builder learns it through this side channel to
|
|
5
|
+
// detect a manifest cut that the catalog would silently not mirror.
|
|
6
|
+
const denyQnsByManifest = new WeakMap<AgentManifest, ReadonlySet<string>>();
|
|
7
|
+
|
|
8
|
+
export function recordManifestDenyQns(manifest: AgentManifest, denyQns: ReadonlySet<string>): void {
|
|
9
|
+
denyQnsByManifest.set(manifest, denyQns);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertCatalogMirrorsManifestDenyQns(
|
|
13
|
+
manifest: AgentManifest,
|
|
14
|
+
catalogDenyQns: ReadonlySet<string>,
|
|
15
|
+
): void {
|
|
16
|
+
const manifestDenyQns = denyQnsByManifest.get(manifest);
|
|
17
|
+
// skip: manifest has no registered deny list, nothing to mirror
|
|
18
|
+
if (!manifestDenyQns) return;
|
|
19
|
+
const missing = [...manifestDenyQns].filter((qn) => !catalogDenyQns.has(qn));
|
|
20
|
+
if (missing.length > 0) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`agent denyQns passed to buildAgentManifest but not to buildToolCatalog: ${missing.join(", ")} — the catalog would still expose their entity CRUD tools`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { hasAccess, normalizeAccessEntry } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { FILTER_OPS, isRecord } from "./filter-ops.js";
|
|
9
|
+
import { assertCatalogMirrorsManifestDenyQns } from "./manifest-deny-qns.js";
|
|
9
10
|
import type {
|
|
10
11
|
AgentManifest,
|
|
11
12
|
AgentManifestEntity,
|
|
@@ -628,7 +629,8 @@ function addClientTools(manifest: AgentManifest, mode: AgentToolMode, sink: Cata
|
|
|
628
629
|
* to drop an entity CRUD tool, which no `agent.expose` on the manifest side can reach. It is
|
|
629
630
|
* NOT read off the manifest on purpose — the manifest is prompt payload, and a list of the
|
|
630
631
|
* handlers the model may not call has no business travelling to the provider. Pass the same
|
|
631
|
-
* list to `buildAgentManifest` so the manifest stops describing what the catalog withholds
|
|
632
|
+
* list to `buildAgentManifest` so the manifest stops describing what the catalog withholds;
|
|
633
|
+
* a manifest built with `denyQns` that the catalog lacks throws. */
|
|
632
634
|
export function buildToolCatalog(
|
|
633
635
|
registry: RegistrySearchView,
|
|
634
636
|
manifest: AgentManifest,
|
|
@@ -639,6 +641,7 @@ export function buildToolCatalog(
|
|
|
639
641
|
const locale = manifest.tenantSettings.locale;
|
|
640
642
|
const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
|
|
641
643
|
const denyQns = new Set(options.denyQns ?? []);
|
|
644
|
+
assertCatalogMirrorsManifestDenyQns(manifest, denyQns);
|
|
642
645
|
|
|
643
646
|
addRegistrySearchTools(registry, roleFilter, denyQns, sink);
|
|
644
647
|
const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Audit list paging no longer repeats rows with ascending sort",
|
|
6
|
+
"detail": "Audit list pages forward without repeats when sorted ascending\n`audit:query:list` with `sortDirection: \"asc\"` returned the same rows again on the next page because the cursor always meant \"older than\". The cursor now follows the sort direction, and ties are broken by event id. Sorting by `type` returns a single page without `nextCursor`, since an event id cannot resume a type-ordered listing."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.346.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -52,6 +52,7 @@ function buildAuditWhere(
|
|
|
52
52
|
cursor?: string;
|
|
53
53
|
before?: string;
|
|
54
54
|
},
|
|
55
|
+
direction: "asc" | "desc",
|
|
55
56
|
): WhereObject {
|
|
56
57
|
const where: WhereObject = { tenantId };
|
|
57
58
|
if (p.aggregateType) where["aggregateType"] = p.aggregateType;
|
|
@@ -62,7 +63,9 @@ function buildAuditWhere(
|
|
|
62
63
|
const range = buildDateRange(p.from, p.to);
|
|
63
64
|
if (range) where["createdAt"] = range;
|
|
64
65
|
const cursor = p.cursor ?? p.before;
|
|
65
|
-
|
|
66
|
+
// The cursor is an event id, so it only walks pages in id order: ascending
|
|
67
|
+
// pages continue past it, descending pages stop before it.
|
|
68
|
+
if (cursor) where["id"] = direction === "asc" ? { gt: BigInt(cursor) } : { lt: BigInt(cursor) };
|
|
66
69
|
return where;
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -94,10 +97,15 @@ export const listQuery = defineQueryHandler({
|
|
|
94
97
|
handler: async (query, ctx) => {
|
|
95
98
|
const p = query.payload;
|
|
96
99
|
const scopeFilter = resolveAuditScopeFilter(query.user, p.scope);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
const direction = p.sortDirection ?? "desc";
|
|
101
|
+
const where = buildAuditWhere(
|
|
102
|
+
scopeFilter.tenantId,
|
|
103
|
+
{
|
|
104
|
+
...p,
|
|
105
|
+
aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
|
|
106
|
+
},
|
|
107
|
+
direction,
|
|
108
|
+
);
|
|
101
109
|
|
|
102
110
|
const rows = await selectMany<{
|
|
103
111
|
id: bigint;
|
|
@@ -110,10 +118,11 @@ export const listQuery = defineQueryHandler({
|
|
|
110
118
|
createdAt: unknown;
|
|
111
119
|
createdBy: string;
|
|
112
120
|
}>(ctx.db, eventsTable, where, {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
// `id` as tie-breaker keeps the order total, so pages never overlap.
|
|
122
|
+
orderBy: [
|
|
123
|
+
{ col: p.sort === "type" ? "type" : "createdAt", direction },
|
|
124
|
+
{ col: "id", direction },
|
|
125
|
+
],
|
|
117
126
|
limit: p.limit,
|
|
118
127
|
});
|
|
119
128
|
|
|
@@ -129,7 +138,8 @@ export const listQuery = defineQueryHandler({
|
|
|
129
138
|
createdBy: r.createdBy,
|
|
130
139
|
}));
|
|
131
140
|
const last = serialised[serialised.length - 1];
|
|
132
|
-
|
|
141
|
+
// An id cursor cannot resume a type-ordered listing, so that sort is one page.
|
|
142
|
+
const nextId = serialised.length === p.limit && last && p.sort !== "type" ? last.id : null;
|
|
133
143
|
return {
|
|
134
144
|
rows: serialised,
|
|
135
145
|
nextCursor: nextId,
|
|
@@ -196,9 +196,8 @@ export function createSignupConfirmHandler(opts: SignupConfirmOptions = {}) {
|
|
|
196
196
|
displayName,
|
|
197
197
|
tenantId,
|
|
198
198
|
tenantKey,
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
tenantName: email,
|
|
199
|
+
// Generated slug as default name: no personal data (the email) in the tenant name.
|
|
200
|
+
tenantName: tenantKey,
|
|
202
201
|
},
|
|
203
202
|
// #1463: seedTenant's postSave hooks (tier-engine's auto-default-
|
|
204
203
|
// tier, an app's auto-default-compliance) must fire on self-signup
|
|
@@ -58,7 +58,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
58
58
|
confirm: {
|
|
59
59
|
handler: AuthMfaHandlers.enableConfirm,
|
|
60
60
|
fields: {
|
|
61
|
-
code: { type: "text", required: true, maxLength: 6 },
|
|
61
|
+
code: { type: "text", required: true, minLength: 6, maxLength: 6 },
|
|
62
62
|
},
|
|
63
63
|
layout: { sections: [{ fields: ["code"] }] },
|
|
64
64
|
carry: ["setupToken"],
|
|
@@ -69,7 +69,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
69
69
|
|
|
70
70
|
// Either a 6-digit TOTP code or a 9-char recovery code proves possession —
|
|
71
71
|
// same bounds as the disable/regenerate-recovery handler schemas.
|
|
72
|
-
const possessionCodeField = { type: "text", required: true, maxLength: 9 } as const;
|
|
72
|
+
const possessionCodeField = { type: "text", required: true, minLength: 6, maxLength: 9 } as const;
|
|
73
73
|
|
|
74
74
|
export const mfaDisableScreen: ActionFormScreenDefinition = {
|
|
75
75
|
id: MFA_DISABLE_SCREEN_ID,
|
|
@@ -45,10 +45,9 @@ export type BillingInfo<TTier extends string> = {
|
|
|
45
45
|
export type BillingInfoQueryDeps<TTier extends string> = {
|
|
46
46
|
readonly roles: readonly string[];
|
|
47
47
|
readonly resolveTier: (db: TenantDb, tenantId: TenantId) => Promise<TTier>;
|
|
48
|
-
// ctx is the same HandlerContext the returned handler receives —
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// so they type their own ctx param as `unknown` and cast internally.
|
|
48
|
+
// ctx is the same HandlerContext the returned handler receives — apps read
|
|
49
|
+
// their extraContext fields (e.g. `billingPrices`) off it and validate them
|
|
50
|
+
// at that boundary.
|
|
52
51
|
readonly getBillingPrices: (
|
|
53
52
|
ctx: HandlerContext,
|
|
54
53
|
) => Readonly<Partial<Record<string, string>>> | null;
|
|
@@ -274,6 +274,13 @@ export type OpenCheckoutOptions = {
|
|
|
274
274
|
readonly baseUrl?: string;
|
|
275
275
|
readonly catalog?: BillingPlanCatalog;
|
|
276
276
|
readonly now: () => Temporal.Instant;
|
|
277
|
+
/** The caller already ran `assertBillingEnabled` under its own handler name
|
|
278
|
+
* (before a costly provider lookup), so the gate is not repeated here. */
|
|
279
|
+
readonly billingEnabledChecked?: boolean;
|
|
280
|
+
/** The caller already ran `assertNoActiveSubscription` and passes its result
|
|
281
|
+
* (null = tenant has none); mode:"subscription" skips the second read and
|
|
282
|
+
* the providerCustomerId check reuses it. */
|
|
283
|
+
readonly checkedSubscription?: Awaited<ReturnType<typeof getSubscriptionForTenant>>;
|
|
277
284
|
};
|
|
278
285
|
|
|
279
286
|
export type OpenCheckoutInput = {
|
|
@@ -358,7 +365,9 @@ export async function assertCheckoutAllowed(
|
|
|
358
365
|
// Checked before the redirect-origin hardening for every mode — a
|
|
359
366
|
// disabled provider must reject a mode:"payment" checkout just as much
|
|
360
367
|
// as a mode:"subscription" one.
|
|
361
|
-
|
|
368
|
+
if (options.billingEnabledChecked !== true) {
|
|
369
|
+
await assertBillingEnabled(ctx, plugin, "create-checkout-session");
|
|
370
|
+
}
|
|
362
371
|
assertRedirectOrigins([input.successUrl, input.cancelUrl], options.baseUrl);
|
|
363
372
|
|
|
364
373
|
const mode = input.mode ?? "subscription";
|
|
@@ -368,7 +377,10 @@ export async function assertCheckoutAllowed(
|
|
|
368
377
|
|
|
369
378
|
if (mode === "subscription") {
|
|
370
379
|
assertSubscriptionPriceAllowed(plugin, options.catalog, input);
|
|
371
|
-
ownSubscription =
|
|
380
|
+
ownSubscription =
|
|
381
|
+
options.checkedSubscription !== undefined
|
|
382
|
+
? options.checkedSubscription
|
|
383
|
+
: await assertNoActiveSubscription(ctx, options.now());
|
|
372
384
|
} else {
|
|
373
385
|
assertOneOffPriceAllowed(plugin, input);
|
|
374
386
|
}
|
package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts
CHANGED
|
@@ -74,7 +74,13 @@ export function createStartPlanCheckoutHandler(
|
|
|
74
74
|
const baseUrl = options.baseUrl ?? "";
|
|
75
75
|
const result = await openCheckout(
|
|
76
76
|
ctx,
|
|
77
|
-
{
|
|
77
|
+
{
|
|
78
|
+
baseUrl: options.baseUrl,
|
|
79
|
+
catalog,
|
|
80
|
+
now: options.now,
|
|
81
|
+
billingEnabledChecked: true,
|
|
82
|
+
checkedSubscription: existing,
|
|
83
|
+
},
|
|
78
84
|
{
|
|
79
85
|
providerName,
|
|
80
86
|
priceId: resolved.priceId,
|
|
@@ -4,14 +4,19 @@ import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framewor
|
|
|
4
4
|
import {
|
|
5
5
|
createEntityExecutor,
|
|
6
6
|
type HandlerContext,
|
|
7
|
+
type SessionUser,
|
|
7
8
|
type TenantId,
|
|
8
9
|
type WriteResult,
|
|
9
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
-
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
|
+
import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
12
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
12
13
|
import * as z from "zod";
|
|
13
14
|
import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
17
|
+
ROLLING_INCREMENTED_EVENT_QN,
|
|
18
|
+
ROLLING_RELEASED_EVENT_QN,
|
|
19
|
+
} from "./constants.js";
|
|
15
20
|
import { capCounterEntity } from "./entity.js";
|
|
16
21
|
|
|
17
22
|
const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
@@ -39,7 +44,7 @@ function sleepMs(ms: number): Promise<void> {
|
|
|
39
44
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
async function retryCounterWriteOnVersionConflict(
|
|
47
|
+
export async function retryCounterWriteOnVersionConflict(
|
|
43
48
|
writeAttempt: () => Promise<WriteResult>,
|
|
44
49
|
): Promise<WriteResult> {
|
|
45
50
|
let result: WriteResult = await writeAttempt();
|
|
@@ -54,6 +59,20 @@ async function retryCounterWriteOnVersionConflict(
|
|
|
54
59
|
return result;
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// Event-store appends signal a lost race by throwing; the whole attempt (read, guard, append) is repeated.
|
|
63
|
+
export async function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T> {
|
|
64
|
+
for (let tries = 1; ; tries++) {
|
|
65
|
+
try {
|
|
66
|
+
return await attempt();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!(error instanceof VersionConflictError) || tries >= MAX_COUNTER_WRITE_ATTEMPTS) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
await sleepMs(Math.random() * tries * BACKOFF_JITTER_MS_PER_ATTEMPT);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
export type BookCapUsageOptions = {
|
|
58
77
|
readonly capName: string;
|
|
59
78
|
readonly periodStartIso: string;
|
|
@@ -63,7 +82,7 @@ export type BookCapUsageOptions = {
|
|
|
63
82
|
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
64
83
|
};
|
|
65
84
|
|
|
66
|
-
function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
|
|
85
|
+
export function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
|
|
67
86
|
if (!ctx.dbOutsideTransaction) {
|
|
68
87
|
throw new Error(
|
|
69
88
|
"cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined",
|
|
@@ -87,64 +106,70 @@ export async function releaseCapUsage(
|
|
|
87
106
|
return applyCapDelta(ctx, options, "subtract");
|
|
88
107
|
}
|
|
89
108
|
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
// One attempt against an already-open db handle; a lost race comes back as a version_conflict failure the caller retries.
|
|
110
|
+
export async function applyCapDeltaOn(
|
|
111
|
+
db: TenantDb,
|
|
112
|
+
user: SessionUser,
|
|
92
113
|
options: BookCapUsageOptions,
|
|
93
114
|
direction: "add" | "subtract",
|
|
94
115
|
): Promise<WriteResult> {
|
|
95
116
|
const parsed = capBookingSchema.parse(options);
|
|
96
|
-
const aggregateId = capCounterAggregateId(
|
|
97
|
-
ctx.user.tenantId,
|
|
98
|
-
parsed.capName,
|
|
99
|
-
parsed.periodStartIso,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
async function attemptWrite(db: TenantDb): Promise<WriteResult> {
|
|
103
|
-
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
104
|
-
if (existing.length === 0) {
|
|
105
|
-
options.guardCurrentValue?.(0);
|
|
106
|
-
if (direction === "subtract") return { isSuccess: true, data: {} };
|
|
107
|
-
return executor.create(
|
|
108
|
-
{
|
|
109
|
-
id: aggregateId,
|
|
110
|
-
capName: parsed.capName,
|
|
111
|
-
value: parsed.amount,
|
|
112
|
-
periodStart: Temporal.Instant.from(parsed.periodStartIso),
|
|
113
|
-
lastSoftWarnedAt: null,
|
|
114
|
-
},
|
|
115
|
-
ctx.user,
|
|
116
|
-
db,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
117
|
+
const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
|
|
119
118
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
|
|
126
|
-
options.guardCurrentValue?.(currentValue);
|
|
127
|
-
return executor.update(
|
|
119
|
+
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
120
|
+
if (existing.length === 0) {
|
|
121
|
+
options.guardCurrentValue?.(0);
|
|
122
|
+
if (direction === "subtract") return { isSuccess: true, data: {} };
|
|
123
|
+
return executor.create(
|
|
128
124
|
{
|
|
129
125
|
id: aggregateId,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? currentValue + parsed.amount
|
|
135
|
-
: Math.max(0, currentValue - parsed.amount),
|
|
136
|
-
},
|
|
126
|
+
capName: parsed.capName,
|
|
127
|
+
value: parsed.amount,
|
|
128
|
+
periodStart: Temporal.Instant.from(parsed.periodStartIso),
|
|
129
|
+
lastSoftWarnedAt: null,
|
|
137
130
|
},
|
|
138
|
-
|
|
131
|
+
user,
|
|
139
132
|
db,
|
|
140
133
|
);
|
|
141
134
|
}
|
|
142
135
|
|
|
136
|
+
const currentRow = existing[0];
|
|
137
|
+
if (!currentRow) {
|
|
138
|
+
throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
|
|
139
|
+
}
|
|
140
|
+
const currentValue = currentRow["value"] as number; // @cast-boundary db-row
|
|
141
|
+
const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
|
|
142
|
+
options.guardCurrentValue?.(currentValue);
|
|
143
|
+
return executor.update(
|
|
144
|
+
{
|
|
145
|
+
id: aggregateId,
|
|
146
|
+
version: currentVersion,
|
|
147
|
+
changes: {
|
|
148
|
+
value:
|
|
149
|
+
direction === "add"
|
|
150
|
+
? currentValue + parsed.amount
|
|
151
|
+
: Math.max(0, currentValue - parsed.amount),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
user,
|
|
155
|
+
db,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function applyCapDelta(
|
|
160
|
+
ctx: HandlerContext,
|
|
161
|
+
options: BookCapUsageOptions,
|
|
162
|
+
direction: "add" | "subtract",
|
|
163
|
+
): Promise<WriteResult> {
|
|
143
164
|
if (options.outsideTransaction) {
|
|
144
165
|
const outsideDb = requireOutsideTransactionDb(ctx);
|
|
145
|
-
return retryCounterWriteOnVersionConflict(() =>
|
|
166
|
+
return retryCounterWriteOnVersionConflict(() =>
|
|
167
|
+
runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)),
|
|
168
|
+
);
|
|
146
169
|
}
|
|
147
|
-
return retryCounterWriteOnVersionConflict(() =>
|
|
170
|
+
return retryCounterWriteOnVersionConflict(() =>
|
|
171
|
+
applyCapDeltaOn(ctx.db, ctx.user, options, direction),
|
|
172
|
+
);
|
|
148
173
|
}
|
|
149
174
|
|
|
150
175
|
export type MarkCapSoftWarnedOptions = {
|
|
@@ -205,12 +230,11 @@ export type ReadRollingCapUsageOptions = {
|
|
|
205
230
|
readonly windowDays: number;
|
|
206
231
|
};
|
|
207
232
|
|
|
208
|
-
|
|
209
|
-
// (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
|
|
210
|
-
export async function readRollingCapUsage(
|
|
233
|
+
async function sumRollingEventAmounts(
|
|
211
234
|
db: TenantDb,
|
|
212
235
|
tenantId: TenantId,
|
|
213
236
|
options: ReadRollingCapUsageOptions,
|
|
237
|
+
type: string,
|
|
214
238
|
): Promise<number> {
|
|
215
239
|
const aggregateId = rollingCapAggregateId(tenantId, options.capName);
|
|
216
240
|
const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
|
|
@@ -219,20 +243,37 @@ export async function readRollingCapUsage(
|
|
|
219
243
|
tenantId,
|
|
220
244
|
aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
221
245
|
aggregateId,
|
|
222
|
-
type
|
|
246
|
+
type,
|
|
223
247
|
createdAt: { gte: cutoff },
|
|
224
248
|
});
|
|
225
249
|
|
|
226
|
-
let
|
|
250
|
+
let total = 0;
|
|
227
251
|
for (const row of rows) {
|
|
228
252
|
// @cast-boundary engine-payload — events.payload is jsonb (typed as
|
|
229
253
|
// unknown by drizzle's $type<Record<string,unknown>>); narrowing the
|
|
230
254
|
// shape here mirrors enforceRollingCap's own read-side contract.
|
|
231
255
|
const payload = row["payload"] as { amount?: number };
|
|
232
256
|
if (typeof payload.amount === "number") {
|
|
233
|
-
|
|
257
|
+
total += payload.amount;
|
|
234
258
|
}
|
|
235
259
|
}
|
|
260
|
+
return total;
|
|
261
|
+
}
|
|
236
262
|
|
|
237
|
-
|
|
263
|
+
// Sums usage without throwing — for callers that only need the raw number
|
|
264
|
+
// (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
|
|
265
|
+
// Incremented minus released amounts inside the window; a release whose increment already left the window cannot push it below 0.
|
|
266
|
+
export async function readRollingCapUsage(
|
|
267
|
+
db: TenantDb,
|
|
268
|
+
tenantId: TenantId,
|
|
269
|
+
options: ReadRollingCapUsageOptions,
|
|
270
|
+
): Promise<number> {
|
|
271
|
+
const incremented = await sumRollingEventAmounts(
|
|
272
|
+
db,
|
|
273
|
+
tenantId,
|
|
274
|
+
options,
|
|
275
|
+
ROLLING_INCREMENTED_EVENT_QN,
|
|
276
|
+
);
|
|
277
|
+
const released = await sumRollingEventAmounts(db, tenantId, options, ROLLING_RELEASED_EVENT_QN);
|
|
278
|
+
return Math.max(0, incremented - released);
|
|
238
279
|
}
|