@cosmicdrift/kumiko-samples 0.352.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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
  4. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  5. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  6. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  7. package/packages/bundled-features/src/audit/changes.json +6 -0
  8. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
  10. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  11. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
  12. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  13. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  14. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  15. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  16. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  17. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  18. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  19. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  20. package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
  21. package/packages/bundled-features/src/folders/feature.ts +2 -12
  22. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  23. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  24. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  25. package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
  26. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  27. package/packages/bundled-features/src/ledger/entity.ts +4 -0
  28. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  29. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  30. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  31. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  32. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  33. package/packages/bundled-features/src/seo/changes.json +5 -0
  34. package/packages/bundled-features/src/seo/feature.ts +23 -7
  35. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  36. package/packages/bundled-features/src/shared/index.ts +2 -1
  37. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  38. package/packages/bundled-features/src/tags/feature.ts +2 -12
  39. package/packages/bundled-features/src/tenant/changes.json +7 -0
  40. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  41. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  42. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  43. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  44. package/packages/bundled-features/src/tenant/index.ts +1 -0
  45. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  46. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  47. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  48. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  49. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  50. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  51. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
  52. package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
  53. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  54. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  55. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  56. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  57. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
  58. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
  59. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  60. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  61. package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
  62. package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
  63. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  64. package/samples/recipes/public-share-token/README.md +4 -5
  65. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  66. package/samples/recipes/security-baseline/README.md +6 -0
  67. package/samples/recipes/tenant-settings/README.md +1 -1
  68. 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.352.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.352.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>",
@@ -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: labelsForSuffix(translations, `screen:${shortId}.title`),
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
- return {
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
- if (cursor) where["id"] = { lt: BigInt(cursor) };
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 where = buildAuditWhere(scopeFilter.tenantId, {
98
- ...p,
99
- aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
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
- orderBy: {
114
- col: query.payload.sort === "type" ? "type" : "createdAt",
115
- direction: query.payload.sortDirection ?? "desc",
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
- const nextId = serialised.length === p.limit && last ? last.id : null;
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
- // Tenant-Display-Name als Default = Email. User wechselt das im
200
- // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
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 — app
49
- // implementations read app-specific extraContext fields off it (e.g.
50
- // publicstatus/show-pony's getBillingPrices(ctx) reads `billingPrices`),
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
- await assertBillingEnabled(ctx, plugin, "create-checkout-session");
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 = await assertNoActiveSubscription(ctx, options.now());
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
  }
@@ -74,7 +74,13 @@ export function createStartPlanCheckoutHandler(
74
74
  const baseUrl = options.baseUrl ?? "";
75
75
  const result = await openCheckout(
76
76
  ctx,
77
- { baseUrl: options.baseUrl, catalog, now: options.now },
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,
@@ -1,4 +1,4 @@
1
- import { access } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, BUILT_IN_MEMBERSHIP_ROLES } from "@cosmicdrift/kumiko-framework/engine";
2
2
 
3
3
  // Usage numbers are an operator concern by default; wider visibility is an
4
4
  // explicit `usageVisibleTo` opt-in on createCapOverviewFeature. The my-caps
@@ -9,14 +9,7 @@ export const DEFAULT_CAP_USAGE_ROLES: readonly string[] = access.roles(...access
9
9
  // Roles the engine itself knows (role-assignment.ts ranks). App-declared roles
10
10
  // (assignableRole) only exist once the registry is composed, which is after
11
11
  // this feature is defined, so they cannot be validated here.
12
- const BUILT_IN_USAGE_VISIBLE_ROLES: ReadonlySet<string> = new Set([
13
- "User",
14
- "Member",
15
- "Editor",
16
- "Admin",
17
- "TenantAdmin",
18
- "SystemAdmin",
19
- ]);
12
+ const BUILT_IN_USAGE_VISIBLE_ROLES: ReadonlySet<string> = new Set(BUILT_IN_MEMBERSHIP_ROLES);
20
13
 
21
14
  export function resolveCapUsageRoles(
22
15
  usageVisibleTo: readonly string[] | undefined,
@@ -10,7 +10,7 @@ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/er
10
10
  import * as z from "zod";
11
11
  import { subscriptionsProjectionTable } from "../../billing-foundation/index.js";
12
12
  import type { CapLimitContext } from "../../cap-counter/index.js";
13
- import { tenantTable } from "../../tenant/index.js";
13
+ import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
14
14
  import { tierAssignmentEntity } from "../../tier-engine/index.js";
15
15
  import { capFieldName } from "../constants.js";
16
16
  import type { CapSpec, CapUsage } from "../types.js";
@@ -174,7 +174,10 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
174
174
  "cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview",
175
175
  );
176
176
 
177
- const tenants = await selectMany<TenantRow>(db, tenantTable, {});
177
+ const tenants = await decryptTenantNames(
178
+ await selectMany<TenantRow>(db, tenantTable, {}),
179
+ "cap-overview:tenant-caps:list",
180
+ );
178
181
  const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
179
182
  const subscriptions = await selectMany<SubscriptionRow>(db, subscriptionsProjectionTable, {});
180
183
 
@@ -2,7 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
- import { tenantTable } from "../../tenant/index.js";
5
+ import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
6
6
 
7
7
  type TenantRow = { readonly id: string; readonly name: string };
8
8
 
@@ -22,14 +22,11 @@ export const tenantOptionsQuery = defineQueryHandler({
22
22
  const db = ctx.systemDb.acknowledgeCrossTenant(
23
23
  "cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options",
24
24
  );
25
- const tenants = await selectMany<TenantRow>(
26
- db,
27
- tenantTable,
28
- {},
29
- {
30
- orderBy: { col: "name", direction: "asc" },
31
- },
25
+ const tenants = await decryptTenantNames(
26
+ await selectMany<TenantRow>(db, tenantTable, {}),
27
+ "cap-overview:tenant-options",
32
28
  );
33
- return { rows: tenants.map((tenant) => ({ value: tenant.id, label: tenant.name })) };
29
+ const sorted = [...tenants].sort((a, b) => a.name.localeCompare(b.name));
30
+ return { rows: sorted.map((tenant) => ({ value: tenant.id, label: tenant.name })) };
34
31
  },
35
32
  });
@@ -3,7 +3,7 @@ import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
5
  import { tenantComplianceProfileTable } from "../../compliance-profiles/index.js";
6
- import { tenantTable } from "../../tenant/index.js";
6
+ import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
7
7
 
8
8
  // SystemAdmin platform-wide counterpart to needs-profile (#2089).
9
9
  //
@@ -34,9 +34,10 @@ export const tenantsMissingProfileQuery = defineQueryHandler({
34
34
  "platform operator scans every tenant for a missing compliance-profile selection",
35
35
  );
36
36
 
37
- const tenants = await db.selectMany<{ id: TenantId; name: string }>(tenantTable, {
38
- isEnabled: true,
39
- });
37
+ const tenants = await decryptTenantNames(
38
+ await db.selectMany<{ id: TenantId; name: string }>(tenantTable, { isEnabled: true }),
39
+ "compliance-profiles-ops:tenants-missing-profile",
40
+ );
40
41
  const profileRows = await db.selectMany<{ tenantId: TenantId }>(
41
42
  tenantComplianceProfileTable,
42
43
  {},
@@ -13,8 +13,10 @@ import { hasConfigAccess } from "../write-helpers.js";
13
13
  export const valuesQuery = defineQueryHandler({
14
14
  name: "values",
15
15
  description:
16
- "Returns the effective value, scope and winning source for every config key the caller may read, with encrypted and secret-backed values masked; use it to inspect the current settings.",
17
- schema: z.object({}),
16
+ "Returns the effective value, scope and winning source for every config key the caller may read (optionally narrowed to `keys`), with encrypted and secret-backed values masked; use it to inspect the current settings.",
17
+ schema: z.object({
18
+ keys: z.array(z.string()).optional(),
19
+ }),
18
20
  // Per-key read access enforced via hasConfigAccess inside the handler.
19
21
  access: {
20
22
  openToAll: {
@@ -37,7 +39,10 @@ export const valuesQuery = defineQueryHandler({
37
39
  const allKeys = registry.getAllConfigKeys();
38
40
  const keyDefs = new Map<string, ConfigKeyDefinition>();
39
41
  const filteredKeys: string[] = [];
42
+ const requestedKeys =
43
+ query.payload.keys === undefined ? undefined : new Set(query.payload.keys);
40
44
  for (const [qualifiedKey, keyDef] of allKeys) {
45
+ if (requestedKeys !== undefined && !requestedKeys.has(qualifiedKey)) continue;
41
46
  if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
42
47
  keyDefs.set(qualifiedKey, keyDef);
43
48
  filteredKeys.push(qualifiedKey);
@@ -11,7 +11,7 @@ export const DeliveryHandlers = {
11
11
  resubscribeUser: "delivery:write:resubscribe-user",
12
12
  } as const;
13
13
 
14
- // Fixed so links mailed out today keep working — the unsubscribe route is
14
+ // Stable path: links signed against it keep working. The unsubscribe route is
15
15
  // mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
16
16
  export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
17
17
 
@@ -170,8 +170,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
170
170
  }
171
171
  // Provider resolved — a list()/delete() failure from here IS fail-closed:
172
172
  // the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
173
- // it, and the next sweep tick retries (list+delete are idempotent, so this
174
- // converges rather than double-deleting or erroring on a missing key).
173
+ // it and retries up to the stage's attempt cap before abandoning the destroy
174
+ // (list+delete are idempotent, so a retry converges rather than
175
+ // double-deleting or erroring on a missing key).
175
176
  for (const prefix of tenantStoragePrefixes(tenantId)) {
176
177
  const keys = await provider.list(prefix);
177
178
  const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
@@ -25,7 +25,7 @@ import {
25
25
  type EntityDefinition,
26
26
  type FeatureRegistrar,
27
27
  } from "@cosmicdrift/kumiko-framework/engine";
28
- import { hasWhereRule } from "../shared/index.js";
28
+ import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
29
29
  import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
30
30
  import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
31
31
  import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
@@ -166,17 +166,7 @@ export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof f
166
166
  ) {
167
167
  return foldersFeature;
168
168
  }
169
- if (hasWhereRule(opts.ownership?.write)) {
170
- throw new Error(
171
- "createFoldersFeature({ ownership }): ownership.write must not contain a " +
172
- '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
173
- "layer (the read path, via buildOwnershipClause). Write paths that " +
174
- "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
175
- "evaluate them, so such a rule can only ever deny — boot validation " +
176
- "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
177
- "leave it unset.",
178
- );
179
- }
169
+ assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
180
170
  if (opts.parents !== undefined && opts.parents.length === 0) {
181
171
  throw new Error(
182
172
  "createFoldersFeature({ parents }): parents must not be an empty array — " +
@@ -1 +1,7 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "The stale-draft cleanup keeps sweeping when a whole batch was re-saved in the meantime"
6
+ }
7
+ ]
@@ -57,6 +57,7 @@ export async function selectStaleDraftsBatch(
57
57
  /** Re-check staleness after the select→release window so a freshly saved draft is not deleted. */
58
58
  export async function isDraftStillStale(
59
59
  db: DbConnection,
60
+ tenantId: TenantId,
60
61
  id: string,
61
62
  olderThanDays: number,
62
63
  ): Promise<boolean> {
@@ -64,9 +65,10 @@ export async function isDraftStillStale(
64
65
  db,
65
66
  `SELECT 1 FROM "read_form_drafts"
66
67
  WHERE "id" = $1
67
- AND COALESCE("modified_at", "inserted_at") < now() - ($2::int * interval '1 day')
68
+ AND "tenant_id" = $2
69
+ AND COALESCE("modified_at", "inserted_at") < now() - ($3::int * interval '1 day')
68
70
  LIMIT 1`,
69
- [id, olderThanDays],
71
+ [id, tenantId, olderThanDays],
70
72
  )) as readonly unknown[];
71
73
  return rows.length > 0;
72
74
  }
@@ -121,20 +121,24 @@ export function groupStaleDraftIdsByTenant(
121
121
  // resurrects, PII included. formDraftExecutor.delete() is the event-sourced
122
122
  // path; it needs a tenant-scoped db + a SessionUser, so rows are grouped by
123
123
  // tenant first (see groupStaleDraftIdsByTenant above).
124
- async function deleteStaleDraftsBatch(
124
+ export async function deleteStaleDraftsBatch(
125
125
  batch: readonly StaleDraftRow[],
126
126
  db: DbConnection,
127
127
  retentionDays: number,
128
128
  log: AppContext["log"],
129
- ): Promise<readonly StaleDraftRow[]> {
129
+ ): Promise<{ readonly deletedRows: readonly StaleDraftRow[]; readonly skippedCount: number }> {
130
130
  const deletedRows: StaleDraftRow[] = [];
131
+ let skippedCount = 0;
131
132
  const byId = new Map(batch.map((row) => [row.id, row]));
132
133
  for (const [tenantId, ids] of groupStaleDraftIdsByTenant(batch)) {
133
134
  const systemUser = createSystemUser(tenantId);
134
135
  const tenantDb = createTenantDb(db, tenantId, "system");
135
136
  for (const id of ids) {
136
137
  // Race: user may have saved between select and here — skip if no longer stale.
137
- if (!(await isDraftStillStale(db, id, retentionDays))) continue;
138
+ if (!(await isDraftStillStale(db, tenantId, id, retentionDays))) {
139
+ skippedCount++;
140
+ continue;
141
+ }
138
142
  const result = await formDraftExecutor.delete({ id }, systemUser, tenantDb);
139
143
  if (result.isSuccess) {
140
144
  const row = byId.get(id);
@@ -146,7 +150,7 @@ async function deleteStaleDraftsBatch(
146
150
  }
147
151
  }
148
152
  }
149
- return deletedRows;
153
+ return { deletedRows, skippedCount };
150
154
  }
151
155
 
152
156
  export async function cleanupDraftsJob(
@@ -189,12 +193,20 @@ export async function cleanupDraftsJob(
189
193
  // entirely for rows with no FileRefs in the blob, the common case.
190
194
  // Delete first (with staleness re-check), then release binaries from rows
191
195
  // that actually disappeared — never release a draft the user just saved.
192
- const deletedRows = await deleteStaleDraftsBatch(batch, db, retentionDays, ctx.log);
196
+ const { deletedRows, skippedCount } = await deleteStaleDraftsBatch(
197
+ batch,
198
+ db,
199
+ retentionDays,
200
+ ctx.log,
201
+ );
193
202
  for (const row of deletedRows) {
194
203
  await releaseRowFileRefs(row, db, fileProviderResolver, ctx.log);
195
204
  }
196
205
  deleted += deletedRows.length;
197
- if (deletedRows.length === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
206
+ // A skipped row is no longer stale, so the next select drops it — a fully
207
+ // skipped batch still makes progress. Only a batch with neither deletes nor
208
+ // skips (all failed) would reselect the same rows forever.
209
+ if (deletedRows.length + skippedCount === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
198
210
  }
199
211
  ctx.log?.info?.(`[form-draft:cleanup] deleted=${deleted} retentionDays=${retentionDays}`);
200
212
  }