@cosmicdrift/kumiko-framework 0.368.0 → 0.369.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.
@@ -106,7 +106,7 @@ Requires: `delivery`, `secrets`
106
106
 
107
107
  ## channel-email
108
108
 
109
- Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
109
+ Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
110
110
 
111
111
  Category: notifications
112
112
 
@@ -206,7 +206,7 @@ Exposes APIs: `retention.policyFor`
206
206
 
207
207
  ## delivery
208
208
 
209
- The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
209
+ The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
210
210
 
211
211
  Category: notifications
212
212
 
@@ -413,7 +413,7 @@ German UI and mail copy for framework screens. Opt-in; not included by includeBu
413
413
 
414
414
  ## mail-foundation
415
415
 
416
- Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.
416
+ Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.
417
417
 
418
418
  Category: notifications
419
419
 
@@ -558,7 +558,7 @@ Requires: `renderer-foundation`
558
558
 
559
559
  ## secrets
560
560
 
561
- Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).
561
+ Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: "secrets:write:set", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.
562
562
 
563
563
  Category: infrastructure
564
564
 
@@ -551,6 +551,25 @@ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNa
551
551
  function isWriteOnlyField(fdef) {
552
552
  return fdef?.writeOnly === true; // @cast-boundary schema-walk
553
553
  }
554
+ // A secret on a configEdit form keeps its value out of the client like an encrypted
555
+ // config key does, and the secrets store only exists per tenant.
556
+ function validateConfigEditSecretField(featureName, screen, fieldName, secretQn, featureMap) {
557
+ const where = `[Feature ${featureName}] Screen "${screen.id}" (configEdit) field "${fieldName}"`;
558
+ if (screen.configKeys[fieldName] !== undefined) {
559
+ throw new Error(`${where} is mapped in both configKeys and secretKeys — keep one.`);
560
+ }
561
+ if (screen.scope !== "tenant") {
562
+ throw new Error(`${where} maps secret "${secretQn}" on a ${screen.scope}-scope screen — secrets are tenant-scoped.`);
563
+ }
564
+ const fdef = screen.fields[fieldName];
565
+ if (fdef?.type !== "text" || !isWriteOnlyField(fdef)) {
566
+ throw new Error(`${where} maps secret "${secretQn}" but is no writeOnly text field — a stored secret never goes back to the client.`);
567
+ }
568
+ const declared = [...featureMap.values()].some((feature) => Object.values(feature.secretKeys ?? {}).some((def) => def.qualifiedName === secretQn));
569
+ if (!declared) {
570
+ throw new Error(`${where} → secret "${secretQn}" is not declared via r.secret() in any feature.`);
571
+ }
572
+ }
554
573
  // Inline forms have no stored value to hide or keep: writeOnly there would
555
574
  // render a "set / keep" affordance with nothing behind it.
556
575
  function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
@@ -1477,9 +1496,19 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1477
1496
  }
1478
1497
  }
1479
1498
  validateWizardLayout(feature.name, screenId, "configEdit", screen.layout, featureMap);
1499
+ for (const fname of Object.keys(screen.secretKeys ?? {})) {
1500
+ if (!fieldNames.has(fname)) {
1501
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) secretKeys names unknown field "${fname}".`);
1502
+ }
1503
+ }
1480
1504
  // configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
1481
1505
  // jeder qualifizierte Key muss in der Registry existieren.
1482
1506
  for (const fname of fieldNames) {
1507
+ const secretQn = screen.secretKeys?.[fname];
1508
+ if (secretQn !== undefined) {
1509
+ validateConfigEditSecretField(feature.name, screen, fname, secretQn, featureMap);
1510
+ continue;
1511
+ }
1483
1512
  const qualified = screen.configKeys[fname];
1484
1513
  if (qualified === undefined) {
1485
1514
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has ` +
@@ -17,9 +17,10 @@
17
17
  import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
18
18
  import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
19
19
  import { isEncryptedAtRest } from "./config-helpers.js";
20
- import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
20
+ import { EXTENSION_SELECTOR_HINT_KEY, isSelectableInSettings, settingsSelectablePluginIds, } from "./extension-selector-plugins.js";
21
21
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
22
22
  import { isKebabSegment, toKebab } from "./qualified-name.js";
23
+ import { isFieldsEditSection, sectionFieldSpecs } from "./screen-helpers.js";
23
24
  import { isOpenToAllGranted } from "./types/handlers.js";
24
25
  // Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
25
26
  // (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
@@ -45,7 +46,7 @@ const PROVIDER_PANEL_TITLE_ENTRY = {
45
46
  es: "Proveedor",
46
47
  };
47
48
  const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
48
- const SAVE_PROVIDER_LABEL_KEY = "config.settings.saveProvider";
49
+ const SECRETS_SECTION_DESCRIPTION_KEY = "config.secrets.description";
49
50
  const audienceNavShortId = (scope) => `audience-${scope}`;
50
51
  // Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
51
52
  export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
@@ -73,25 +74,43 @@ const NO_SELECTOR_GATING = {
73
74
  };
74
75
  // Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
75
76
  // (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
77
+ // Merged per locale: a locale feature (localeDe) declares the same key with only its
78
+ // own language, and mounted first it must not drop the owning feature's en text.
76
79
  function collectDeclaredTranslations(registry) {
77
80
  const declared = new Map();
78
81
  for (const f of registry.features.values()) {
79
82
  for (const [key, values] of Object.entries(f.translations ?? {})) {
80
- if (!declared.has(key))
81
- declared.set(key, values);
83
+ declared.set(key, { ...values, ...declared.get(key) });
82
84
  }
83
85
  }
84
86
  return declared;
85
87
  }
88
+ // A selector whose plugins are all hidden from settings still renders as a select
89
+ // (with no option), never as a free text field.
86
90
  function collectSelectorOptions(registry) {
87
91
  const options = new Map();
88
92
  for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
89
- const pluginIds = selectablePluginIds(registry, extensionName);
90
- if (pluginIds.length > 0)
91
- options.set(selectorKey, pluginIds);
93
+ if (registry.getExtensionUsages(extensionName).length === 0)
94
+ continue;
95
+ options.set(selectorKey, settingsSelectablePluginIds(registry, extensionName));
92
96
  }
93
97
  return options;
94
98
  }
99
+ // selector key → plugin id → plugin feature, for every selectable plugin, also those
100
+ // without settings of their own (no panel, but still an option with a name).
101
+ function collectSelectorOptionFeatures(registry) {
102
+ const byKey = new Map();
103
+ for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
104
+ const featureByPluginId = new Map();
105
+ for (const usage of registry.getExtensionUsages(extensionName).filter(isSelectableInSettings)) {
106
+ if (usage.featureName === undefined || featureByPluginId.has(usage.entityName))
107
+ continue;
108
+ featureByPluginId.set(usage.entityName, toKebab(usage.featureName));
109
+ }
110
+ byKey.set(selectorKey, featureByPluginId);
111
+ }
112
+ return byKey;
113
+ }
95
114
  function collectVisibleSelectorOwners(registry, visible) {
96
115
  const ownerGroupByExtension = new Map();
97
116
  const selectorFeatures = new Set();
@@ -141,6 +160,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
141
160
  const secretQns = new Set();
142
161
  for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
143
162
  const usages = [...registry.getExtensionUsages(extension)].sort((a, b) => (a.featureName ?? "").localeCompare(b.featureName ?? ""));
163
+ const selectorQn = registry.getAllExtensionSelectors().get(extension);
164
+ if (selectorQn === undefined)
165
+ continue;
144
166
  for (const usage of usages) {
145
167
  if (usage.featureName === undefined)
146
168
  continue;
@@ -148,11 +170,22 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
148
170
  if (usageCountByFeature.get(feature) !== 1 || selectorFeatures.has(feature))
149
171
  continue;
150
172
  const scopedKeys = visible.filter((v) => v.key.ownerFeature === feature);
151
- const secrets = secretsEnabled ? declaredSecrets.filter((s) => s.feature === feature) : [];
173
+ // The provider form has no per-field access gate, so a secret with its own write
174
+ // roles stays on the secrets screen, which enforces them.
175
+ const secrets = secretsEnabled
176
+ ? declaredSecrets.filter((s) => s.feature === feature && s.def.writeRoles === undefined)
177
+ : [];
152
178
  if (scopedKeys.length === 0 && secrets.length === 0)
153
179
  continue;
154
180
  const plugins = pluginsByOwnerGroup.get(ownerGroup) ?? [];
155
- plugins.push({ feature, extension, pluginId: usage.entityName, scopedKeys, secrets });
181
+ plugins.push({
182
+ feature,
183
+ extension,
184
+ selectorQn,
185
+ pluginId: usage.entityName,
186
+ scopedKeys,
187
+ secrets,
188
+ });
156
189
  pluginsByOwnerGroup.set(ownerGroup, plugins);
157
190
  pluginFeatures.add(feature);
158
191
  for (const s of secrets)
@@ -204,9 +237,6 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
204
237
  }
205
238
  return out;
206
239
  }
207
- function pluginTenantScreenId(plugin) {
208
- return `${plugin.feature}-tenant`;
209
- }
210
240
  function assertUniquePanelIds(ownerGroup, panels) {
211
241
  const seen = new Set();
212
242
  for (const panel of panels) {
@@ -218,63 +248,38 @@ function assertUniquePanelIds(ownerGroup, panels) {
218
248
  }
219
249
  return panels;
220
250
  }
221
- // Plugin screens keep their `${F}-tenant` ids so consumer title keys stay valid;
222
- // they are dormant because only this dashboard embeds them.
251
+ // One form for the provider and the settings of every provider: a single save writes
252
+ // all of it, so switching sections can't drop typed input. Each plugin's fields are
253
+ // shown only while its provider is picked in the form; hidden fields are not saved.
223
254
  function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
224
255
  const selectionScreenId = `${ownerGroup}-tenant-selection`;
225
- const selectionScreen = {
226
- ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
227
- description: EXTENSION_SELECTOR_HINT_KEY,
228
- sectionTitle: PROVIDER_SECTION_TITLE_KEY,
229
- submitLabel: SAVE_PROVIDER_LABEL_KEY,
230
- }),
231
- dormant: true,
232
- };
233
- const screens = [selectionScreen];
234
- const configPanels = [];
235
- const secretsPanels = [];
256
+ const form = new ProviderFormBuilder(selectionScreenId, buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
257
+ description: EXTENSION_SELECTOR_HINT_KEY,
258
+ sectionTitle: PROVIDER_SECTION_TITLE_KEY,
259
+ }));
236
260
  const accessRules = [ownerAccess];
237
261
  const translations = {};
238
262
  translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
239
- Object.assign(translations, providerOptionTranslations(selectionScreen, plugins, hub));
263
+ Object.assign(translations, providerOptionTranslations(form.base, hub));
240
264
  for (const plugin of plugins) {
241
- const visibleWhen = {
242
- query: SELECTED_EXTENSIONS_QUERY,
243
- field: plugin.extension,
244
- eq: plugin.pluginId,
245
- };
265
+ const providerFieldId = form.fieldIdOfConfigKey(plugin.selectorQn);
266
+ const visible = { field: providerFieldId, eq: plugin.pluginId };
267
+ let pluginSection;
246
268
  if (plugin.scopedKeys.length > 0) {
247
269
  const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
248
- const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
249
- screens.push({
250
- ...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
251
- dormant: true,
252
- });
270
+ const pluginScreen = buildScreen(`${plugin.feature}-tenant`, "tenant", plugin.feature, sortByMaskOrder(plugin.scopedKeys.map((v) => v.key)), pluginAccess, hub, { description: null, fieldIdOf: qualifiedFieldId });
271
+ pluginSection = form.addConfigFields(pluginScreen, visible);
253
272
  accessRules.push(pluginAccess);
254
- configPanels.push({
255
- kind: "screen",
256
- id: `${plugin.feature}-config`,
257
- screen: pluginTenantScreenId(plugin),
258
- visibleWhen,
259
- chromeless: true,
260
- });
261
273
  }
262
274
  if (plugin.secrets.length > 0) {
263
- const secretsScreenId = `${plugin.feature}-tenant-secrets`;
264
- const generated = buildSecretsEditScreen(secretsScreenId, plugin.secrets, secretsAccess, hub.declaredTranslationKeys);
265
- screens.push({ ...generated.screen, dormant: true });
275
+ const generated = buildSecretsEditScreen(`${plugin.feature}-tenant-secrets`, plugin.secrets, secretsAccess, hub.declaredTranslationKeys);
276
+ form.addSecretFields(generated.screen, plugin.feature, visible, pluginSection);
266
277
  Object.assign(translations, generated.translations);
267
278
  accessRules.push(secretsAccess);
268
- secretsPanels.push({
269
- kind: "screen",
270
- id: `${plugin.feature}-secrets`,
271
- screen: secretsScreenId,
272
- visibleWhen,
273
- chromeless: true,
274
- });
275
279
  }
276
280
  }
277
281
  const access = unionAccessRules(accessRules);
282
+ const selectionScreen = { ...form.build(access), dormant: true };
278
283
  const dashboard = {
279
284
  id: `${ownerGroup}-tenant`,
280
285
  type: "dashboard",
@@ -282,16 +287,136 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
282
287
  panels: assertUniquePanelIds(ownerGroup, [
283
288
  { kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
284
289
  ...ownerPanels,
285
- ...configPanels,
286
- ...secretsPanels,
287
290
  ]),
288
291
  access,
289
292
  };
290
- return { screens: [dashboard, ...screens], access, translations };
293
+ return { screens: [dashboard, selectionScreen], access, translations };
294
+ }
295
+ // A plugin key keeps its feature in the field id: the provider form holds the keys of
296
+ // several features side by side.
297
+ function qualifiedFieldId(k) {
298
+ return `${k.ownerFeature}-${k.shortKey}`;
299
+ }
300
+ class ProviderFormBuilder {
301
+ screenId;
302
+ base;
303
+ configKeys;
304
+ secretKeys = {};
305
+ fields;
306
+ fieldLabels;
307
+ fieldDescriptions;
308
+ requiredFields;
309
+ sections;
310
+ constructor(screenId, base) {
311
+ this.screenId = screenId;
312
+ this.base = base;
313
+ this.configKeys = { ...base.configKeys };
314
+ this.fields = { ...base.fields };
315
+ this.fieldLabels = { ...base.fieldLabels };
316
+ this.fieldDescriptions = { ...base.fieldDescriptions };
317
+ this.requiredFields = [...(base.requiredFields ?? [])];
318
+ this.sections = base.layout.sections.filter(isFieldsEditSection);
319
+ }
320
+ fieldIdOfConfigKey(qn) {
321
+ const entry = Object.entries(this.base.configKeys).find(([, keyQn]) => keyQn === qn);
322
+ if (entry === undefined) {
323
+ throw new Error(`[Settings-Hub] selector key "${qn}" is not on "${this.screenId}".`);
324
+ }
325
+ return entry[0];
326
+ }
327
+ addConfigFields(pluginScreen, visible) {
328
+ for (const [id, qn] of Object.entries(pluginScreen.configKeys)) {
329
+ this.claim(id, qn);
330
+ this.configKeys[id] = qn;
331
+ const field = pluginScreen.fields[id];
332
+ if (field !== undefined)
333
+ this.fields[id] = field;
334
+ }
335
+ Object.assign(this.fieldLabels, pluginScreen.fieldLabels);
336
+ Object.assign(this.fieldDescriptions, pluginScreen.fieldDescriptions);
337
+ this.requiredFields.push(...(pluginScreen.requiredFields ?? []));
338
+ const section = pluginScreen.layout.sections.find(isFieldsEditSection);
339
+ if (section === undefined)
340
+ return undefined;
341
+ const { groups: _groups, ...flatSection } = section;
342
+ const gated = {
343
+ ...flatSection,
344
+ fields: gateFields(sectionFieldSpecs(section), visible),
345
+ };
346
+ this.sections.push(gated);
347
+ return gated;
348
+ }
349
+ // A plugin's sign-in data gets its own section when the plugin names one, else it
350
+ // continues the plugin's settings section.
351
+ addSecretFields(secretsScreen, feature, visible, pluginSection) {
352
+ for (const [id, qn] of Object.entries(secretsScreen.secretKeys)) {
353
+ this.claim(id, qn);
354
+ this.secretKeys[id] = qn;
355
+ this.fields[id] = createTextField({
356
+ personal: false,
357
+ reason: "system_metadata",
358
+ writeOnly: true,
359
+ });
360
+ }
361
+ Object.assign(this.fieldLabels, secretsScreen.fieldLabels);
362
+ Object.assign(this.fieldDescriptions, secretsScreen.fieldHints);
363
+ this.requiredFields.push(...(secretsScreen.requiredFields ?? []));
364
+ const secretsTitleKey = `${feature}.settings.secrets`;
365
+ for (const section of secretsScreen.sections) {
366
+ // kumiko-lint-ignore section-fields-raw a secretsEdit section is a flat key list without groups
367
+ const fields = gateFields(section.fields, visible);
368
+ const ownSection = section.title === secretsTitleKey || pluginSection === undefined;
369
+ if (ownSection) {
370
+ this.sections.push({
371
+ title: section.title ?? `${feature}.settings`,
372
+ description: SECRETS_SECTION_DESCRIPTION_KEY,
373
+ fields,
374
+ });
375
+ continue;
376
+ }
377
+ const index = this.sections.indexOf(pluginSection);
378
+ this.sections[index] = {
379
+ ...pluginSection,
380
+ fields: [...sectionFieldSpecs(pluginSection), ...fields],
381
+ };
382
+ }
383
+ }
384
+ build(access) {
385
+ const { fieldDescriptions: _baseDescriptions, requiredFields: _baseRequired, ...base } = this.base;
386
+ return {
387
+ ...base,
388
+ configKeys: this.configKeys,
389
+ ...(Object.keys(this.secretKeys).length > 0 && { secretKeys: this.secretKeys }),
390
+ fields: this.fields,
391
+ fieldLabels: this.fieldLabels,
392
+ ...(Object.keys(this.fieldDescriptions).length > 0 && {
393
+ fieldDescriptions: this.fieldDescriptions,
394
+ }),
395
+ ...(this.requiredFields.length > 0 && { requiredFields: this.requiredFields }),
396
+ layout: { ...base.layout, sections: this.sections },
397
+ access,
398
+ };
399
+ }
400
+ claim(id, qn) {
401
+ const previous = this.configKeys[id] ?? this.secretKeys[id];
402
+ if (previous !== undefined) {
403
+ throw new Error(`[Settings-Hub] "${previous}" and "${qn}" both resolve to field id "${id}" on "${this.screenId}" — rename one key.`);
404
+ }
405
+ }
406
+ }
407
+ function gateFields(fields, visible) {
408
+ return fields.map((spec) => typeof spec === "string" ? { field: spec, visible } : { ...spec, visible });
291
409
  }
292
410
  // Everything generated for one audience scope: the audience-parent nav, the
293
411
  // per-feature configEdit screens+navs, and (tenant-scope only, when secrets
294
412
  // are enabled) the secrets screen. Null when nothing is visible at this scope.
413
+ // The secrets set handler also admits the machine role for server-side writes; like the
414
+ // config keys' write roles, that role must not reach a screen or nav access gate.
415
+ function withoutMachineRole(rule) {
416
+ if (rule === undefined || !("roles" in rule))
417
+ return rule;
418
+ return { ...rule, roles: rule.roles.filter((role) => role !== MACHINE_WRITE_ROLE) };
419
+ }
295
420
  function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, declaredSecrets, hub, registry) {
296
421
  const visible = scopedKeysAt(masked, scope);
297
422
  // Secrets attach to the tenant-audience nav regardless of whether any
@@ -300,7 +425,7 @@ function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, de
300
425
  if (visible.length === 0 && !includeSecrets)
301
426
  return null;
302
427
  const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
303
- const secretsAccess = secretsWriteHandler?.access;
428
+ const secretsAccess = withoutMachineRole(secretsWriteHandler?.access);
304
429
  const gating = scope === "tenant"
305
430
  ? planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
306
431
  : NO_SELECTOR_GATING;
@@ -346,6 +471,7 @@ export function buildConfigFeatureSchema(registry) {
346
471
  const hub = {
347
472
  declaredTranslationKeys: new Set(translationsByKey.keys()),
348
473
  selectorOptions: collectSelectorOptions(registry),
474
+ selectorOptionFeatures: collectSelectorOptionFeatures(registry),
349
475
  translationsByKey,
350
476
  };
351
477
  const screens = [];
@@ -440,7 +566,12 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
440
566
  if (s.def.writeRoles !== undefined)
441
567
  fieldAccess[fieldId] = { roles: [...s.def.writeRoles] };
442
568
  }
443
- const titleKey = `${feature}.settings`;
569
+ // Opt-in `${feature}.settings.secrets`: in a selector dashboard this section sits right
570
+ // under the plugin's own settings section, which already carries `${feature}.settings`.
571
+ const secretsTitleKey = `${feature}.settings.secrets`;
572
+ const titleKey = declaredTranslationKeys.has(secretsTitleKey)
573
+ ? secretsTitleKey
574
+ : `${feature}.settings`;
444
575
  sections.push({
445
576
  ...(declaredTranslationKeys.has(titleKey) && { title: titleKey }),
446
577
  fields: fieldIds,
@@ -514,7 +645,8 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
514
645
  // feature's group (100% of today's apps — byte-identical output). Only a
515
646
  // cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
516
647
  // to keep two features sharing a group from clobbering the same shortKey.
517
- const fieldId = (k) => k.feature === k.ownerFeature ? k.shortKey : `${k.ownerFeature}-${k.shortKey}`;
648
+ const fieldId = options.fieldIdOf ??
649
+ ((k) => (k.feature === k.ownerFeature ? k.shortKey : qualifiedFieldId(k)));
518
650
  const seenFieldIds = new Map();
519
651
  for (const k of keys) {
520
652
  const id = fieldId(k);
@@ -544,7 +676,6 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
544
676
  ...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
545
677
  ...(requiredFields.length > 0 && { requiredFields }),
546
678
  ...(screenDescription !== undefined && { description: screenDescription }),
547
- ...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
548
679
  layout: { sections: [section], width: "full", variant: "settings-list" },
549
680
  access,
550
681
  };
@@ -589,16 +720,17 @@ function buildSettingsSection(feature, scope, fieldIds, hub, options) {
589
720
  }
590
721
  // Option labels of the provider select: each plugin feature's `<feature>.settings`
591
722
  // copy, under the option-label key the configEdit renderer already derives.
592
- function providerOptionTranslations(selectionScreen, plugins, hub) {
723
+ function providerOptionTranslations(selectionScreen, hub) {
593
724
  const out = {};
594
725
  for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
595
- if (!hub.selectorOptions.has(qn))
726
+ const featureByPluginId = hub.selectorOptionFeatures.get(qn);
727
+ if (featureByPluginId === undefined)
596
728
  continue;
597
- for (const plugin of plugins) {
598
- const label = hub.translationsByKey.get(`${plugin.feature}.settings`);
729
+ for (const [pluginId, feature] of featureByPluginId) {
730
+ const label = hub.translationsByKey.get(`${feature}.settings`);
599
731
  if (label === undefined)
600
732
  continue;
601
- const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${plugin.pluginId}`;
733
+ const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${pluginId}`;
602
734
  out[key] = label;
603
735
  }
604
736
  }
@@ -1,5 +1,8 @@
1
1
  import type { Registry } from "./types/feature.js";
2
+ import type { RegistrarExtensionRegistration } from "./types/index.js";
2
3
  export declare const SELECTED_EXTENSIONS_QUERY = "config:query:config-value:selected-extensions";
3
4
  export declare const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHint";
4
5
  export declare function selectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
6
+ export declare function isSelectableInSettings(usage: RegistrarExtensionRegistration): boolean;
7
+ export declare function settingsSelectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
5
8
  export declare function extensionSelectorTargets(registry: Pick<Registry, "getAllExtensionSelectors">): ReadonlyMap<string, string>;
@@ -5,6 +5,20 @@ export const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHin
5
5
  export function selectablePluginIds(registry, extensionName) {
6
6
  return [...new Set(registry.getExtensionUsages(extensionName).map((u) => u.entityName))].sort();
7
7
  }
8
+ // A plugin registered with `selectableInSettings: false` (a test inbox) stays a valid
9
+ // stored value for seeds and dev setups, but the generated settings never offer it.
10
+ const SELECTABLE_IN_SETTINGS_OPTION = "selectableInSettings";
11
+ export function isSelectableInSettings(usage) {
12
+ return usage.options?.[SELECTABLE_IN_SETTINGS_OPTION] !== false;
13
+ }
14
+ export function settingsSelectablePluginIds(registry, extensionName) {
15
+ return [
16
+ ...new Set(registry
17
+ .getExtensionUsages(extensionName)
18
+ .filter(isSelectableInSettings)
19
+ .map((u) => u.entityName)),
20
+ ].sort();
21
+ }
8
22
  export function extensionSelectorTargets(registry) {
9
23
  const extensionByKey = new Map();
10
24
  for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
@@ -28,6 +28,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
28
28
  readonly renamedFrom?: readonly string[];
29
29
  readonly systemOnly?: boolean;
30
30
  readonly unsubscribe?: boolean;
31
+ readonly tenantTransport?: boolean;
31
32
  }), definition?: {
32
33
  readonly trigger?: {
33
34
  readonly on: NameOrRef;
@@ -43,6 +44,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
43
44
  readonly renamedFrom?: readonly string[];
44
45
  readonly systemOnly?: boolean;
45
46
  readonly unsubscribe?: boolean;
47
+ readonly tenantTransport?: boolean;
46
48
  }): void;
47
49
  translations(def: TranslationsDef): void;
48
50
  defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
@@ -222,6 +222,7 @@ export function buildConfigEventsJobsMethods(state, name) {
222
222
  : {}),
223
223
  ...(resolvedDefinition.systemOnly === true ? { systemOnly: true } : {}),
224
224
  ...(resolvedDefinition.unsubscribe === true ? { unsubscribe: true } : {}),
225
+ ...(resolvedDefinition.tenantTransport === true ? { tenantTransport: true } : {}),
225
226
  };
226
227
  },
227
228
  translations(def) {
@@ -16,10 +16,9 @@ export const SETTINGS_HUB_I18N = {
16
16
  "config.settings.tenant": { en: "Tenant" },
17
17
  "config.settings.user": { en: "Personal" },
18
18
  "config.settings.extensionSelectorHint": {
19
- en: "Choose a provider and save. Its settings appear below.",
19
+ en: "Choose a provider. Its settings appear below and are saved together with it.",
20
20
  },
21
21
  "config.settings.provider": { en: "Provider" },
22
- "config.settings.saveProvider": { en: "Save provider" },
23
22
  "config.settings.audience.system": {
24
23
  en: "Applies to every tenant unless a tenant sets its own value.",
25
24
  },
@@ -446,7 +446,7 @@ function writePartialMarker(out, targetDir, pending, manualEntries, failedVersio
446
446
  });
447
447
  out.err(` ⚠ ${ran.length} codemod(s) already applied; wrote marker at ${version} so a re-run resumes at the failed one.`);
448
448
  }
449
- // Runs every pending breaking entry's codemod, oldest version first (so a
449
+ // Runs every pending entry's codemod, oldest version first (so a
450
450
  // later codemod can assume an earlier one already ran). Stops on the first
451
451
  // failure; codemods that already ran are kept in a partial marker. Writes the
452
452
  // marker whenever dryRun is false —
@@ -474,7 +474,9 @@ markerVersion, previous) {
474
474
  return 0;
475
475
  }
476
476
  const breaking = pending.filter((e) => e.type === "breaking");
477
- const codemodEntries = breaking
477
+ // A codemod is worth running whatever the entry's type: an improvement that
478
+ // moves a module ships one too, and skipping it leaves the deprecated path in place.
479
+ const codemodEntries = pending
478
480
  .filter(hasCodemod)
479
481
  .sort((a, b) => compareVersions(a.version, b.version));
480
482
  const manualEntries = breaking.filter(needsManualMigration);
@@ -485,7 +487,7 @@ markerVersion, previous) {
485
487
  }
486
488
  if (codemodEntries.length === 0) {
487
489
  out.log(breaking.length > 0
488
- ? " No automatable codemods among the pending breaking changes."
490
+ ? " No automatable codemods among the pending changes."
489
491
  : " ✓ No breaking changes pending.");
490
492
  if (!dryRun) {
491
493
  const carried = carryOverManual(previous, manualEntries.map(toMarkerManual));
@@ -636,7 +638,7 @@ kumiko-upgrade --resolve <id|version>[,<id|version>...] --reason "<text>" [--not
636
638
  --dir <path> target directory to check/apply against (default: cwd)
637
639
  --json machine-readable output
638
640
  --verbose include full migration details for each pending entry
639
- --apply run codemods for pending breaking changes and write the upgrade marker
641
+ --apply run codemods for pending changes and write the upgrade marker
640
642
  (one run moves the marker to the installed version; manual migrations stay listed as open)
641
643
  --resolve <refs> mark open manual migrations as done; ref = full id or a version with exactly one open entry
642
644
  --reason <text> required with --resolve, max 500 characters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.368.0",
3
+ "version": "0.369.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -221,8 +221,8 @@
221
221
  "./package.json": "./package.json"
222
222
  },
223
223
  "dependencies": {
224
- "@cosmicdrift/kumiko-http": "0.368.0",
225
- "@cosmicdrift/kumiko-types": "0.368.0",
224
+ "@cosmicdrift/kumiko-http": "0.369.0",
225
+ "@cosmicdrift/kumiko-types": "0.369.0",
226
226
  "bullmq": "^5.76.7",
227
227
  "bun-types": "^1.3.13",
228
228
  "hono": "^4.13.1",
@@ -238,8 +238,8 @@
238
238
  "zod": "^4.4.3"
239
239
  },
240
240
  "devDependencies": {
241
- "@cosmicdrift/kumiko-dispatcher-live": "0.368.0",
242
- "@cosmicdrift/kumiko-testing": "0.368.0",
241
+ "@cosmicdrift/kumiko-dispatcher-live": "0.369.0",
242
+ "@cosmicdrift/kumiko-testing": "0.369.0",
243
243
  "bun-types": "^1.3.13",
244
244
  "pino-pretty": "^13.1.3"
245
245
  },
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.369.0",
4
+ "type": "fix",
5
+ "title": "kumiko-upgrade --apply runs codemods of improvement entries too",
6
+ "detail": "The apply step only ran codemods attached to breaking entries. The 0.368.0 entry that moves the test stack to kumiko-testing is an improvement with a codemod (testing-move), so `bun kumiko-upgrade --apply` skipped it and left the deprecated framework paths in place. Every pending entry with a codemod now runs, in version order; manual-migration reporting is unchanged. Apps whose marker is already at 0.368.0 no longer see that entry as pending, so they run `bun kumiko-upgrade --apply --from 0.367.0` once, or the codemod directly: `bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/testing-move.ts . --write`."
7
+ },
2
8
  {
3
9
  "version": "0.368.0",
4
10
  "type": "fix",