@cosmicdrift/kumiko-bundled-features 0.167.1 → 0.170.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.167.1",
3
+ "version": "0.170.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>",
@@ -119,16 +119,17 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.167.1",
123
- "@cosmicdrift/kumiko-framework": "0.167.1",
124
- "@cosmicdrift/kumiko-headless": "0.167.1",
125
- "@cosmicdrift/kumiko-renderer": "0.167.1",
126
- "@cosmicdrift/kumiko-renderer-web": "0.167.1",
127
- "@cosmicdrift/kumiko-types": "0.167.1",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.170.0",
123
+ "@cosmicdrift/kumiko-framework": "0.170.0",
124
+ "@cosmicdrift/kumiko-headless": "0.170.0",
125
+ "@cosmicdrift/kumiko-renderer": "0.170.0",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.170.0",
127
+ "@cosmicdrift/kumiko-types": "0.170.0",
128
128
  "@mollie/api-client": "^4.5.0",
129
129
  "imapflow": "^1.3.3",
130
130
  "mailparser": "^3.9.8",
131
131
  "@node-rs/argon2": "^2.0.2",
132
+ "@types/mailparser": "^3.4.6",
132
133
  "@types/nodemailer": "^8.0.0",
133
134
  "clsx": "^2.1.1",
134
135
  "lucide-react": "^1.14.0",
@@ -150,7 +151,6 @@
150
151
  ],
151
152
  "devDependencies": {
152
153
  "@testing-library/user-event": "^14.6.1",
153
- "@types/mailparser": "^3.4.6",
154
154
  "@types/qrcode": "^1.5.5"
155
155
  }
156
156
  }
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.165.1",
4
+ "type": "breaking",
5
+ "title": "makeAuthGate / makeSessionAuthGate take a single LoginRouteOptions object (fw#1545).",
6
+ "detail": "The four positional args (LoginComponent, loginProps, MfaVerifyComponent, MfaSetupComponent) did not scale past two optional MFA params. Shipped in the stranded 2.0.0 major and carried into the 0.165.1 line.",
7
+ "migration": "Rewrite call sites as makeAuthGate({ loginScreen, loginScreenProps, mfaVerifyScreen, mfaSetupScreen }). Type-check catches every occurrence."
8
+ },
9
+ {
10
+ "version": "0.167.1",
11
+ "type": "improvement",
12
+ "title": "accountLockout is reachable through runProdApp/runDevApp (fw#1627).",
13
+ "detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock — an app on either entrypoint had no way to turn brute-force protection on. RunProdAppAuthOptions and RunDevAppAuthOptions now carry accountLockout and composeFeatures passes it through.",
14
+ "migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts — no error, no warning, no protection — so treat Redis as a deploy invariant, and prove the lockout with a behavioural test instead of assuming the mount is enough."
15
+ },
2
16
  {
3
17
  "version": "0.162.0",
4
18
  "type": "improvement",
@@ -190,6 +190,9 @@ export type SeedAdminOptions = {
190
190
  * membership-Rollen. Typischer use-case: `["SystemAdmin"]` für
191
191
  * einen Plattform-Operator. Default: leer. */
192
192
  readonly globalRoles?: readonly string[];
193
+ /** Initial-emailVerified-Flag — Default false (unverified),
194
+ * gleicher Mechanismus wie seedUserWithPassword.emailVerified. */
195
+ readonly emailVerified?: boolean;
193
196
  readonly by?: SessionUser;
194
197
  };
195
198
 
@@ -213,6 +216,7 @@ export async function seedAdmin(
213
216
  password: options.password,
214
217
  displayName: options.displayName,
215
218
  ...(options.globalRoles !== undefined && { roles: options.globalRoles }),
219
+ ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
216
220
  by,
217
221
  });
218
222
 
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.165.1",
4
+ "type": "improvement",
5
+ "title": "validateSessionStoreMultiplicity accepts zero sessionStore providers (fw#1547).",
6
+ "detail": "A machine-API-only deployment (PAT-bearer auth, no browser sessions) can mount auth-foundation without also mounting sessions."
7
+ },
8
+ {
9
+ "version": "0.161.0",
10
+ "type": "improvement",
11
+ "title": "tenantResolver/tenantExistence extension points, sessionStore without auth.sessions (fw#1372-1375).",
12
+ "detail": "Auth-foundation migration: tenant resolution and tenant-existence become extension points, sessionStore is wired without going through auth.sessions, AnonymousAccessConfig is slimmed down. See the auth-foundation-providers recipe."
13
+ },
2
14
  {
3
15
  "version": "0.165.0",
4
16
  "type": "fix",
@@ -0,0 +1,37 @@
1
+ // Unit-test for effectiveTierFromSubscription. The DB-touching half
2
+ // (syncTierFromSubscription, the wired route) is exercised via the app-level
3
+ // test suites of its two current consumers (show-pony, publicstatus) — those
4
+ // migrate onto this factory in a follow-up PR (infra#446), carrying their
5
+ // existing integration coverage with them.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { SubscriptionStatuses } from "../constants";
9
+ import { effectiveTierFromSubscription } from "../subscription-tier-sync";
10
+
11
+ type TestTier = "free" | "starter" | "pro";
12
+ const isTierName = (v: string): v is TestTier => v === "free" || v === "starter" || v === "pro";
13
+
14
+ describe("effectiveTierFromSubscription", () => {
15
+ test("active subscription with a known tier → that tier", () => {
16
+ expect(
17
+ effectiveTierFromSubscription(SubscriptionStatuses.active, "pro", isTierName, "free"),
18
+ ).toBe("pro");
19
+ });
20
+
21
+ test("trialing counts as usable", () => {
22
+ expect(
23
+ effectiveTierFromSubscription(SubscriptionStatuses.trialing, "starter", isTierName, "free"),
24
+ ).toBe("starter");
25
+ });
26
+
27
+ test("canceled/past_due/unknown status → default tier", () => {
28
+ expect(effectiveTierFromSubscription("canceled", "pro", isTierName, "free")).toBe("free");
29
+ expect(effectiveTierFromSubscription(undefined, "pro", isTierName, "free")).toBe("free");
30
+ });
31
+
32
+ test("unrecognized tier name → default tier", () => {
33
+ expect(
34
+ effectiveTierFromSubscription(SubscriptionStatuses.active, "enterprise", isTierName, "free"),
35
+ ).toBe("free");
36
+ });
37
+ });
@@ -31,6 +31,13 @@ export {
31
31
  export { billingFoundationFeature } from "./feature";
32
32
  export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant";
33
33
  export { subscriptionsProjectionTable } from "./projection";
34
+ export {
35
+ createSubscriptionTierSync,
36
+ effectiveTierFromSubscription,
37
+ SUBSCRIPTION_WEBHOOK_PATH,
38
+ type SubscriptionTierSyncDeps,
39
+ type SystemWriteResult,
40
+ } from "./subscription-tier-sync";
34
41
  export type {
35
42
  SubscriptionEvent,
36
43
  SubscriptionProviderPlugin,
@@ -0,0 +1,136 @@
1
+ // Generic Stripe/PayPal-webhook → tier-engine sync route. Extracted from the
2
+ // near-identical webhook-route.ts app copies in show-pony and publicstatus
3
+ // (infra#446) — the only per-app variables were the TierName union/default
4
+ // and the tier-assignment table handle, so both are now factory parameters.
5
+
6
+ import {
7
+ TierEngineHandlers,
8
+ tierAssignmentAggregateId,
9
+ } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
10
+ import { type DbRunner, type EntityTable, fetchOne } from "@cosmicdrift/kumiko-framework/db";
11
+ import type { EntityDefinition, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import type { Hono } from "hono";
13
+ import { subscriptionAggregateId } from "./aggregate-id";
14
+ import { SUBSCRIPTION_PROVIDER_EXTENSION, SubscriptionStatuses } from "./constants";
15
+ import { subscriptionsProjectionTable } from "./projection";
16
+ import type { SubscriptionProviderPlugin } from "./types";
17
+ import { createSubscriptionWebhookHandler } from "./webhook-handler";
18
+
19
+ export const SUBSCRIPTION_WEBHOOK_PATH = "/webhooks/subscription/:providerName";
20
+
21
+ export type SystemWriteResult = {
22
+ readonly isSuccess: boolean;
23
+ readonly data?: unknown;
24
+ readonly error?: { readonly code?: string; readonly message?: string };
25
+ };
26
+
27
+ export type SubscriptionTierSyncDeps<TTier extends string> = {
28
+ readonly db: DbRunner;
29
+ readonly registry: Registry;
30
+ readonly dispatchSystemWrite: (args: {
31
+ readonly handlerQn: string;
32
+ readonly payload: unknown;
33
+ readonly tenantId: TenantId;
34
+ }) => Promise<SystemWriteResult>;
35
+ readonly tierAssignmentTable: EntityTable<EntityDefinition>;
36
+ readonly isTierName: (value: string) => value is TTier;
37
+ readonly defaultTier: TTier;
38
+ };
39
+
40
+ export function effectiveTierFromSubscription<TTier extends string>(
41
+ status: string | undefined,
42
+ tier: string | undefined,
43
+ isTierName: (value: string) => value is TTier,
44
+ defaultTier: TTier,
45
+ ): TTier {
46
+ const usable = status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing;
47
+ return usable && typeof tier === "string" && isTierName(tier) ? tier : defaultTier;
48
+ }
49
+
50
+ export function createSubscriptionTierSync<TTier extends string>(
51
+ deps: SubscriptionTierSyncDeps<TTier>,
52
+ ) {
53
+ async function syncTierFromSubscription(
54
+ tenantId: TenantId,
55
+ ): Promise<{ code: string; message: string } | null> {
56
+ const sub = await fetchOne<{ status?: unknown; tier?: unknown }>(
57
+ deps.db,
58
+ subscriptionsProjectionTable,
59
+ { id: subscriptionAggregateId(tenantId) },
60
+ );
61
+ if (!sub) return null;
62
+
63
+ const effective = effectiveTierFromSubscription(
64
+ typeof sub.status === "string" ? sub.status : undefined,
65
+ typeof sub.tier === "string" ? sub.tier : undefined,
66
+ deps.isTierName,
67
+ deps.defaultTier,
68
+ );
69
+
70
+ const assignment = await fetchOne<{ id?: unknown; version?: unknown; tier?: unknown }>(
71
+ deps.db,
72
+ deps.tierAssignmentTable,
73
+ { tenantId },
74
+ );
75
+ if (
76
+ !assignment ||
77
+ typeof assignment.id !== "string" ||
78
+ typeof assignment.version !== "number"
79
+ ) {
80
+ const created = await deps.dispatchSystemWrite({
81
+ handlerQn: TierEngineHandlers.create,
82
+ payload: { id: tierAssignmentAggregateId(tenantId), tier: effective },
83
+ tenantId,
84
+ });
85
+ if (!created.isSuccess) {
86
+ return {
87
+ code: "tier_sync_failed",
88
+ message: `tier-engine create with "${effective}" failed: ${created.error?.code ?? "unknown"}`,
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+ if (assignment.tier === effective) return null;
94
+
95
+ const result = await deps.dispatchSystemWrite({
96
+ handlerQn: TierEngineHandlers.update,
97
+ payload: { id: assignment.id, version: assignment.version, changes: { tier: effective } },
98
+ tenantId,
99
+ });
100
+ if (!result.isSuccess) {
101
+ return {
102
+ code: "tier_sync_failed",
103
+ message: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
104
+ };
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function wireSubscriptionWebhookRoute(app: Hono): void {
110
+ const handler = createSubscriptionWebhookHandler({
111
+ dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
112
+ const targetTenantId = tenantId as TenantId;
113
+ const result = await deps.dispatchSystemWrite({
114
+ handlerQn,
115
+ payload,
116
+ tenantId: targetTenantId,
117
+ });
118
+ if (!result.isSuccess) return result;
119
+ const syncError = await syncTierFromSubscription(targetTenantId);
120
+ if (syncError) {
121
+ return { isSuccess: false, error: syncError };
122
+ }
123
+ return result;
124
+ },
125
+ resolveProvider: (providerName) => {
126
+ const usage = deps.registry
127
+ .getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
128
+ .find((u) => u.entityName === providerName);
129
+ return usage?.options as SubscriptionProviderPlugin | undefined;
130
+ },
131
+ });
132
+ app.post(SUBSCRIPTION_WEBHOOK_PATH, handler);
133
+ }
134
+
135
+ return { wireSubscriptionWebhookRoute };
136
+ }
@@ -26,6 +26,11 @@ export {
26
26
  } from "./enforce-cap";
27
27
  export { capCounterEntity } from "./entity";
28
28
  export { capCounterFeature } from "./feature";
29
+ export {
30
+ createStockCapGuard,
31
+ type StockCapGuard,
32
+ type StockCapSpec,
33
+ } from "./stock-cap-guard";
29
34
  export {
30
35
  type CalendarCapDef,
31
36
  type CalendarCapResolver,
@@ -0,0 +1,68 @@
1
+ // Generic stock-cap write-guard factory. Extracted from the near-identical
2
+ // cap-guard.ts app copies in show-pony and publicstatus (infra#446) — the
3
+ // only per-app variable was the Caps shape and how to resolve it for a
4
+ // tenant, so both are now factory parameters.
5
+
6
+ import { countWhere, type DbRunner, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { TenantId, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ UnprocessableError,
10
+ type WriteFailure,
11
+ writeFailure,
12
+ } from "@cosmicdrift/kumiko-framework/errors";
13
+ import { enforceStockCap } from "./enforce-cap";
14
+
15
+ export type StockCapSpec<TCaps> = {
16
+ readonly table: Parameters<typeof countWhere>[1];
17
+ readonly limit: (caps: TCaps) => number;
18
+ readonly where?: WhereObject;
19
+ readonly code: string;
20
+ readonly i18nKey: string;
21
+ readonly field: string;
22
+ };
23
+
24
+ export type StockCapGuard<TCaps> = {
25
+ readonly checkStockCap: (
26
+ db: DbRunner,
27
+ tenantId: TenantId,
28
+ spec: StockCapSpec<TCaps>,
29
+ ) => Promise<WriteFailure | null>;
30
+ readonly withStockCap: (handler: WriteHandlerDef, spec: StockCapSpec<TCaps>) => WriteHandlerDef;
31
+ };
32
+
33
+ export function createStockCapGuard<TCaps>(
34
+ resolveTierCaps: (db: DbRunner, tenantId: TenantId) => Promise<TCaps>,
35
+ ): StockCapGuard<TCaps> {
36
+ async function checkStockCap(
37
+ db: DbRunner,
38
+ tenantId: TenantId,
39
+ spec: StockCapSpec<TCaps>,
40
+ ): Promise<WriteFailure | null> {
41
+ const caps = await resolveTierCaps(db, tenantId);
42
+ const current = await countWhere(db, spec.table, { tenantId, ...spec.where });
43
+ const { state, limit } = enforceStockCap({
44
+ current,
45
+ limit: spec.limit(caps),
46
+ profile: "hardSlot",
47
+ });
48
+ if (state !== "exceeded") return null;
49
+ return writeFailure(
50
+ new UnprocessableError(spec.code, {
51
+ i18nKey: spec.i18nKey,
52
+ details: { field: spec.field, reason: spec.code, current, limit },
53
+ }),
54
+ );
55
+ }
56
+
57
+ function withStockCap(handler: WriteHandlerDef, spec: StockCapSpec<TCaps>): WriteHandlerDef {
58
+ return {
59
+ ...handler,
60
+ handler: async (event, ctx) => {
61
+ const failure = await checkStockCap(ctx.db.raw, event.user.tenantId, spec);
62
+ return failure ?? handler.handler(event, ctx);
63
+ },
64
+ };
65
+ }
66
+
67
+ return { checkStockCap, withStockCap };
68
+ }
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.166.0",
4
+ "type": "improvement",
5
+ "title": "resolveKmsWiring / requireKmsWiring / buildPgKmsOptions: boot-time KMS env validation (fw#1617).",
6
+ "detail": "The subject-keys KMS env trio was validated by four copy-pasted app-side helpers that had drifted: only one parsed PLATFORM_KEK_VERSION strictly, so \"1e21\", \"0x10\" and \"-1\" were accepted and silently produced an unreachable rotation slot. Ordering of previous vs. active version stays enforced by PgKmsAdapter."
7
+ },
8
+ {
9
+ "version": "0.166.0",
10
+ "type": "fix",
11
+ "title": "KmsWiringEnv accepts process.env directly (fw#1618).",
12
+ "detail": "All members being optional made TypeScript's weak-type detection reject ProcessEnv for having no properties in common, which forced every consumer into a cast or a six-key mapping — the boilerplate fw#1617 set out to remove."
13
+ },
14
+ {
15
+ "version": "0.167.0",
16
+ "type": "breaking",
17
+ "title": "Test-only reset helpers moved from /crypto to /testing (fw#1631).",
18
+ "detail": "resetPiiSubjectKmsForTests and resetBlindIndexKeyForTests are no longer exported by the production barrels. The functions did not change — only the export path. Why it matters beyond tidiness: resetPiiSubjectKmsForTests clears the injected KMS, after which encryptForStorage sees no adapter and writes subject-annotated fields in plaintext, with no error and no log. Reachable from a production barrel, that is one stray import away from silent plaintext PII.",
19
+ "migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter."
20
+ },
2
21
  {
3
22
  "version": "0.165.3",
4
23
  "type": "improvement",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.167.1",
4
+ "type": "fix",
5
+ "title": "blockDelete boot warning only fires for entities that have a subject (fw#1622).",
6
+ "detail": "The warning \"retention.strategy=blockDelete but no field has an anonymize-function\" checked the strategy alone. blockDelete is the legal-hold mode and therefore the obvious choice for anything under a statutory retention period — including entities that hold nothing but business data and foreign keys. Those got warned at every boot about something that cannot be satisfied: there is nothing to anonymize, and User-Forget never reaches a row without a subject. The warning made the correct declaration louder than the wrong one. It now requires at least one field annotated pii/userOwned/tenantOwned.",
7
+ "migration": "None. Warnings disappear for entities without subject-annotated fields; entities that do carry PII still get warned, unchanged."
8
+ },
2
9
  {
3
10
  "version": "0.116.0",
4
11
  "type": "improvement",
@@ -41,7 +41,10 @@ export const mailAccountEntity = createEntity({
41
41
  // Owner + TenantAdmin (Compliance); KEIN Crypto-Subject-Wechsel in
42
42
  // V1 (Subject bleibt Tenant, siehe Header).
43
43
  ownerUserId: createTextField({ maxLength: 36 }),
44
- displayName: createTextField({ maxLength: 200 }),
44
+ displayName: createTextField({
45
+ maxLength: 200,
46
+ allowPlaintext: "mailbox display name, tenant config not personal data",
47
+ }),
45
48
  // Postfach-Adresse — PII des Tenants.
46
49
  address: createTextField({ required: true, maxLength: 1000, tenantOwned: true }),
47
50
  status: createTextField({ required: true, maxLength: 30 }),
@@ -39,7 +39,11 @@ export function createBrandingSettingsScreen(opts: {
39
39
  },
40
40
  fields: {
41
41
  title: createTextField({ maxLength: 200 }),
42
- description: createTextField({ maxLength: 500, multiline: { rows: 3 } }),
42
+ description: createTextField({
43
+ maxLength: 500,
44
+ multiline: { rows: 3 },
45
+ allowPlaintext: "tenant branding copy, business content not personal data",
46
+ }),
43
47
  siteUrl: createTextField({ maxLength: 2000, format: "url" }),
44
48
  accentColor: createTextField({ maxLength: 9 }),
45
49
  logoUrl: createTextField({ maxLength: 2000, format: "url" }),
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.166.0",
4
+ "type": "improvement",
5
+ "title": "derivePurposeSecret: HKDF-based per-purpose secret derivation (fw#1623).",
6
+ "detail": "Previously copy-pasted in four apps as deriveSubSecret. The second parameter is a domain separator, not a label — the rename says so. auth-mfa gains resolveMfaTokenSecrets, which owns the two MFA purpose strings so a prod and a dev entrypoint cannot drift apart and invalidate each other's tokens."
7
+ }
8
+ ]
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.165.1",
4
+ "type": "fix",
5
+ "title": "Mid-stream access revocation aborts idle SSE pulls; sseHeartbeatMs is a real buildServer option (fw#1587).",
6
+ "detail": "A revoked session used to keep an idle SSE stream alive until the client disconnected. clientIpOf now falls back to x-real-ip before the shared \"unknown\" bucket, so per-IP rate limits stop collapsing into one bucket behind proxies that only set that header."
7
+ },
2
8
  {
3
9
  "version": "0.165.0",
4
10
  "type": "improvement",
@@ -245,6 +245,7 @@ describe("text-content :: query (openToAll)", () => {
245
245
  const otherTenant = createTestUser({
246
246
  id: 99,
247
247
  tenantId: "11111111-1111-4111-8111-111111111111",
248
+ roles: ["TenantAdmin"],
248
249
  });
249
250
  await seedTextBlock(db, {
250
251
  tenantId: tenantAdmin.tenantId,
@@ -257,8 +258,7 @@ describe("text-content :: query (openToAll)", () => {
257
258
  { slug: "tenant-only", lang: "de" },
258
259
  otherTenant,
259
260
  );
260
- // null oder undefined je nach pipeline-shape — beides bedeutet "nicht gefunden"
261
- expect(result).toBeFalsy();
261
+ expect(result).toBeNull();
262
262
  });
263
263
 
264
264
  test("by-slug works for SystemAdmin scoped to system tenant", async () => {
@@ -24,4 +24,9 @@ export {
24
24
  createTierEngineFeature,
25
25
  tierEngineFeature,
26
26
  } from "./feature";
27
+ export {
28
+ createTierResolver,
29
+ type TierResolver,
30
+ type TierResolverDeps,
31
+ } from "./tier-resolver";
27
32
  export { isTrialActive, type TrialPolicy } from "./trial";
@@ -0,0 +1,36 @@
1
+ // Generic tier-resolver factory. Extracted from the near-identical
2
+ // tier-resolver.ts app copies in show-pony and publicstatus (infra#446) — the
3
+ // only per-app variables were the TierName union and its caps/defaults, so
4
+ // both are now factory parameters.
5
+
6
+ import { buildEntityTable, type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { tierAssignmentEntity } from "./entity";
9
+
10
+ export type TierResolverDeps<TTier extends string, TCaps> = {
11
+ readonly capsForTier: (tier: TTier) => TCaps;
12
+ readonly isTierName: (value: string) => value is TTier;
13
+ readonly defaultTier: TTier;
14
+ };
15
+
16
+ export function createTierResolver<TTier extends string, TCaps>(
17
+ deps: TierResolverDeps<TTier, TCaps>,
18
+ ) {
19
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
20
+
21
+ async function resolveTier(db: DbRunner, tenantId: TenantId): Promise<TTier> {
22
+ const row = await fetchOne<{ tier?: unknown }>(db, tierAssignmentTable, { tenantId });
23
+ const tier = row?.tier;
24
+ return typeof tier === "string" && deps.isTierName(tier) ? tier : deps.defaultTier;
25
+ }
26
+
27
+ async function resolveTierCaps(db: DbRunner, tenantId: TenantId): Promise<TCaps> {
28
+ return deps.capsForTier(await resolveTier(db, tenantId));
29
+ }
30
+
31
+ return { tierAssignmentTable, resolveTier, resolveTierCaps };
32
+ }
33
+
34
+ export type TierResolver<TTier extends string, TCaps> = ReturnType<
35
+ typeof createTierResolver<TTier, TCaps>
36
+ >;
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.167.1",
4
+ "type": "breaking",
5
+ "title": "user.locale no longer defaults to \"de\" (fw#1637).",
6
+ "detail": "The entity-level default contradicted tenant-settings' own \"en\" default and silently overrode any app or tenant locale configuration for every new user. locale now stays unset until the client or a resolution chain provides one. Consumers that already fall back with `user.locale ?? \"en\"` are unaffected in shape but now see null instead of \"de\".",
7
+ "migration": "Run `kumiko-schema generate` and apply — the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain — the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
8
+ },
9
+ {
10
+ "version": "0.167.1",
11
+ "type": "improvement",
12
+ "title": "user.timezone field, wired into ctx.tz (fw#1636).",
13
+ "detail": "The user entity gains a `timezone` field (createTzField, no default). buildHandlerContext passes it to ctx.tz.user; the fallback chain stays tenant.timezone, then \"UTC\". Before this, ctx.tz.tenant and ctx.tz.user both hardcoded \"UTC\" while the plumbing already accepted per-request values.",
14
+ "migration": "Run `kumiko-schema generate` and apply — the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
15
+ },
2
16
  {
3
17
  "version": "0.165.3",
4
18
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.165.1",
4
+ "type": "fix",
5
+ "title": "restrict-account runs the same cross-tenant membership check as lift-restriction (fw#1543).",
6
+ "detail": "Any Admin/TenantAdmin — not just SystemAdmin — could restrict a user's account and force-revoke their sessions regardless of tenant, as long as they held an admin role in some tenant. Also: the GET /user-export/by-token fallback now forwards x-forwarded-for to the internal query call, so the per-IP 30/min limit stops collapsing into one global bucket."
7
+ },
2
8
  {
3
9
  "version": "0.165.3",
4
10
  "type": "improvement",