@cosmicdrift/kumiko-samples 0.372.0 → 0.374.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-samples",
3
- "version": "0.372.0",
3
+ "version": "0.374.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.372.0",
3
+ "version": "0.374.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>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.374.0",
4
+ "type": "improvement",
5
+ "title": "Client actions for password reset and email verification",
6
+ "detail": "emailPasswordClient registers auth-email-password:request-password-reset and auth-email-password:request-email-verification for clientAction row actions."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -46,6 +46,7 @@ export const defaultTranslations: TranslationsByLocale = {
46
46
  "auth.errors.signupEmailAlreadyRegistered":
47
47
  "An account already exists for this email. Please sign in or reset your password.",
48
48
  "auth.errors.unknownError": "Something went wrong. Please try again.",
49
+ "auth.errors.tooManyRequests": "Too many requests. Please wait briefly.",
49
50
  "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
50
51
  "auth.forgotPassword.title": "Reset password",
51
52
  "auth.forgotPassword.intro":
@@ -0,0 +1,36 @@
1
+ // @runtime client
2
+ // Handlers for RowAction `kind: "clientAction"`: an admin screen lists users and
3
+ // triggers the same public mail flows the login screens use.
4
+
5
+ import type { ClientActionHandler, ClientActionOutcome } from "@cosmicdrift/kumiko-renderer";
6
+ import {
7
+ type AuthTokenFailure,
8
+ requestEmailVerification,
9
+ requestPasswordReset,
10
+ } from "./auth-client.ts";
11
+
12
+ type EmailFlow = (
13
+ email: string,
14
+ ) => Promise<{ readonly ok: true } | { readonly ok: false; readonly error: AuthTokenFailure }>;
15
+
16
+ function failureMessageKey(failure: AuthTokenFailure): string {
17
+ return failure.reason === "rate_limited"
18
+ ? "auth.errors.tooManyRequests"
19
+ : "auth.errors.unknownError";
20
+ }
21
+
22
+ function emailFlowHandler(flow: EmailFlow): ClientActionHandler {
23
+ return async (payload): Promise<ClientActionOutcome> => {
24
+ const email = payload["email"];
25
+ if (typeof email !== "string" || email === "") {
26
+ return { ok: false, message: "auth.errors.invalidBody" };
27
+ }
28
+ const result = await flow(email);
29
+ return result.ok ? { ok: true } : { ok: false, message: failureMessageKey(result.error) };
30
+ };
31
+ }
32
+
33
+ export const emailPasswordClientActions: Readonly<Record<string, ClientActionHandler>> = {
34
+ "auth-email-password:request-password-reset": emailFlowHandler(requestPasswordReset),
35
+ "auth-email-password:request-email-verification": emailFlowHandler(requestEmailVerification),
36
+ };
@@ -5,7 +5,7 @@
5
5
  // Translations (de/en) mit. Alles ist overridbar — Login-Screen,
6
6
  // Strings pro Locale, pro Key.
7
7
 
8
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ClientActionHandler, TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
9
  import type { ComponentType, ReactNode } from "react";
10
10
  import { defaultTranslations, mergeTranslations } from "../i18n.ts";
11
11
  import {
@@ -14,6 +14,7 @@ import {
14
14
  type MfaVerifyComponentProps,
15
15
  makeSessionAuthGate,
16
16
  } from "./auth-gate.tsx";
17
+ import { emailPasswordClientActions } from "./client-actions.ts";
17
18
  import type { LoginScreenProps } from "./login-screen.tsx";
18
19
 
19
20
  export type EmailPasswordClientOptions = {
@@ -55,6 +56,7 @@ export type EmailPasswordClientFeature = {
55
56
  readonly providers: readonly ComponentType<{ children: ReactNode }>[];
56
57
  readonly gates: readonly ComponentType<{ children: ReactNode }>[];
57
58
  readonly translations: TranslationsByLocale;
59
+ readonly clientActions: Readonly<Record<string, ClientActionHandler>>;
58
60
  };
59
61
 
60
62
  export function emailPasswordClient(
@@ -75,5 +77,6 @@ export function emailPasswordClient(
75
77
  }),
76
78
  ],
77
79
  translations,
80
+ clientActions: emailPasswordClientActions,
78
81
  };
79
82
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.374.0",
4
+ "type": "breaking",
5
+ "title": "tenant-caps:list pages by offset and rejects cursor",
6
+ "detail": "The cap overview list showed page 1 on every page because the pager sends offset and the query only read cursor. tenant-caps:list now takes offset like the pager; a cursor in the payload is rejected with a 400 validation error and sort is an enum of the sortable fields. The unused cap-overview.errors.invalidCursor and sortFieldUnsupported keys are gone.",
7
+ "migration": "Callers of cap-overview:query:tenant-caps:list must send offset instead of cursor, and only a sortable field name as sort. Apps that override the removed i18n keys cap-overview.errors.invalidCursor or cap-overview.errors.sortFieldUnsupported can delete them. No caller in the kumiko-enterprise, kumiko-platform, kumiko-studio or app repos sends a cursor or defines these keys."
8
+ },
2
9
  {
3
10
  "version": "0.365.0",
4
11
  "type": "fix",
@@ -1,17 +1,12 @@
1
- import {
2
- buildEntityTable,
3
- decodeCursor,
4
- encodeCursor,
5
- selectMany,
6
- } from "@cosmicdrift/kumiko-framework/db";
1
+ import { buildEntityTable, selectMany } from "@cosmicdrift/kumiko-framework/db";
7
2
  import {
8
3
  type ConfigAccessor,
9
4
  definePagedQueryHandler,
10
- MAX_LIST_LIMIT,
5
+ pagedListSchema,
6
+ pageRows,
11
7
  type TenantId,
12
8
  } from "@cosmicdrift/kumiko-framework/engine";
13
9
  import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
14
- import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
15
10
  import * as z from "zod";
16
11
  import { subscriptionsProjectionTable } from "../../billing-foundation/index.ts";
17
12
  import type { CapLimitContext } from "../../cap-counter/index.ts";
@@ -59,7 +54,6 @@ const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
59
54
  type FilterOp = z.infer<typeof FILTER_OP>;
60
55
 
61
56
  const SORTABLE_FIELDS = ["name", "tier", "billing"] as const;
62
- type SortableField = (typeof SORTABLE_FIELDS)[number];
63
57
 
64
58
  function tenantCapKey(tenantId: string, capId: string): string {
65
59
  return JSON.stringify([tenantId, capId]);
@@ -83,10 +77,6 @@ async function resolveLimitsByTenantAndCap(
83
77
  return new Map(entries);
84
78
  }
85
79
 
86
- function isSortableField(field: string): field is SortableField {
87
- return isOneOf(SORTABLE_FIELDS, field);
88
- }
89
-
90
80
  // tier is a plain string with no defined ordering — lt/gt have no meaning
91
81
  // on it and are rejected rather than silently treated as "no filter".
92
82
  function assertSupportedTierFilterOp(op: FilterOp): void {
@@ -152,16 +142,10 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
152
142
  name: "tenant-caps:list",
153
143
  description:
154
144
  "Lists every tenant on the platform with its tier, billing provider/status and per-cap usage, paged, searchable by name and filterable by tier; use it as the operator-wide capacity overview.",
155
- schema: z.object({
156
- cursor: z.string().optional(),
157
- limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
158
- sort: z.string().optional(),
159
- sortDirection: z.enum(["asc", "desc"]).optional(),
160
- search: z.string().optional(),
145
+ schema: pagedListSchema(SORTABLE_FIELDS).extend({
161
146
  filters: z
162
147
  .array(z.object({ field: z.string(), op: FILTER_OP, value: z.unknown() }))
163
148
  .optional(),
164
- totalCount: z.boolean().optional(),
165
149
  }),
166
150
  access: { roles: ["SystemAdmin"] },
167
151
  escapeHatch: {
@@ -190,68 +174,30 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
190
174
  const assignmentByTenant = new Map(assignments.map((row) => [row.tenantId, row]));
191
175
  const subscriptionByTenant = new Map(subscriptions.map((row) => [row.tenantId, row]));
192
176
 
193
- // Case-insensitive substring search has no `ilike` operator in the
194
- // query builder (only `like`, SQL-LIKE case-sensitive) — filtered here
195
- // in JS, same as tenant/team-list.query.ts's search.
196
- const search = query.payload.search?.trim().toLowerCase();
197
177
  const tierFilter = extractTierFilter(query.payload.filters ?? []);
198
178
 
199
179
  const merged = tenants
200
- .filter(
201
- (tenant) =>
202
- search === undefined || search === "" || tenant.name.toLowerCase().includes(search),
203
- )
204
180
  .map((tenant) => {
205
181
  const assignment = assignmentByTenant.get(tenant.id);
206
182
  return {
183
+ id: tenant.id,
207
184
  tenantId: tenant.id,
208
185
  name: tenant.name,
209
186
  tier: assignment?.tier ?? "",
210
- source: assignment?.source ?? null,
211
187
  billing: billingLabel(subscriptionByTenant.get(tenant.id), assignment?.source ?? null),
212
188
  };
213
189
  })
214
190
  .filter((row) => matchesTierFilter(row.tier, tierFilter));
215
191
 
216
- const sortField = query.payload.sort ?? "name";
217
- if (!isSortableField(sortField)) {
218
- throw new ValidationError({
219
- fields: [
220
- {
221
- path: "sort",
222
- code: "unsupported_field",
223
- i18nKey: "cap-overview.errors.sortFieldUnsupported",
224
- params: { field: sortField },
225
- },
226
- ],
227
- });
228
- }
229
- const sortDirection = query.payload.sortDirection ?? "asc";
230
- const directionMultiplier = sortDirection === "asc" ? 1 : -1;
231
- const sorted = [...merged].sort(
232
- (a, b) => a[sortField].localeCompare(b[sortField]) * directionMultiplier,
233
- );
234
-
235
- // ponytail: base64-wrapped offset, not a SQL keyset — rows come from a
236
- // JS merge of three tables (tenant/tier-assignment/subscription), same
237
- // rationale as tenant/team-list.query.ts. Holds up to tier-engine's
238
- // documented "single-pass scan" scale (a few thousand tenants); past
239
- // that the fix is a real combined read-projection, not a cursor
240
- // bolted onto this merge.
241
- const offset = query.payload.cursor ? Number(decodeCursor(query.payload.cursor)) : 0;
242
- if (!Number.isInteger(offset) || offset < 0) {
243
- throw new ValidationError({
244
- fields: [
245
- {
246
- path: "cursor",
247
- code: "invalid_cursor",
248
- i18nKey: "cap-overview.errors.invalidCursor",
249
- },
250
- ],
251
- });
252
- }
253
- const limit = query.payload.limit;
254
- const page = sorted.slice(offset, offset + limit);
192
+ // Search and paging run in memory because the rows are a JS merge of
193
+ // three tables (tenant/tier-assignment/subscription), same rationale as
194
+ // tenant/team-list.query.ts. Holds up to tier-engine's documented
195
+ // "single-pass scan" scale (a few thousand tenants); past that the fix
196
+ // is a real combined read-projection.
197
+ const { rows: page, total } = pageRows(merged, query.payload, {
198
+ searchFields: ["name"],
199
+ defaultSort: "name",
200
+ });
255
201
 
256
202
  // Usage is computed only for this page's tenant ids, never for the
257
203
  // full tenant set; caps without usageBatch still cost one query per
@@ -302,14 +248,7 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
302
248
  };
303
249
  });
304
250
 
305
- const nextCursor =
306
- offset + limit < sorted.length ? encodeCursor(String(offset + limit)) : null;
307
-
308
- return {
309
- rows,
310
- nextCursor,
311
- ...(query.payload.totalCount === true && { total: merged.length }),
312
- };
251
+ return { rows, nextCursor: null, ...(total !== undefined && { total }) };
313
252
  },
314
253
  });
315
254
  }
@@ -17,11 +17,9 @@ export const CAP_OVERVIEW_I18N: Readonly<Record<string, LocalizedString>> = {
17
17
  "cap-overview.errors.progressPrimitiveMissing": {
18
18
  en: "Usage bar unavailable — Progress primitive is not registered.",
19
19
  },
20
- "cap-overview.errors.invalidCursor": { en: "The pagination cursor is invalid." },
21
20
  "cap-overview.errors.filterFieldUnsupported": {
22
21
  en: "Filtering by this field is not supported, or tier was filtered more than once.",
23
22
  },
24
- "cap-overview.errors.sortFieldUnsupported": { en: "Sorting by this field is not supported." },
25
23
  "cap-overview.errors.tenantOverrideRequiresSystemAdmin": {
26
24
  en: "Only SystemAdmin may query another tenant's data.",
27
25
  },
@@ -190,6 +190,21 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
190
190
  label: "widgets:dashboard:goal-progress",
191
191
  query: "widgets:query:metrics:goal-progress",
192
192
  },
193
+ {
194
+ kind: "text",
195
+ id: "dashboard-help",
196
+ label: "widgets:dashboard:help-title",
197
+ blocks: [
198
+ { kind: "heading", text: "widgets:dashboard:help-heading" },
199
+ { kind: "paragraph", text: "widgets:dashboard:help-body" },
200
+ {
201
+ kind: "list",
202
+ items: ["widgets:dashboard:help-item-1", "widgets:dashboard:help-item-2"],
203
+ },
204
+ { kind: "code", code: "kumiko check --explain" },
205
+ { kind: "hint", text: "widgets:dashboard:help-hint" },
206
+ ],
207
+ },
193
208
  {
194
209
  kind: "custom",
195
210
  id: "filter-echo",
@@ -373,6 +388,8 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
373
388
  value: openAmount(3200, ctx.locale),
374
389
  fraction: 0.92,
375
390
  },
391
+ { label: { i18nKey: "widgets:dashboard:demo-cap-users" }, used: 10, limit: 10 },
392
+ { label: { i18nKey: "widgets:dashboard:demo-cap-projects" }, used: 4, limit: null },
376
393
  ],
377
394
  }),
378
395
  access: {
@@ -36,6 +36,14 @@ export const WIDGETS_I18N = {
36
36
  "widgets:dashboard:col-status": { de: "Status", en: "Status" },
37
37
  "widgets:dashboard:upcoming": { de: "Nächste Termine", en: "Upcoming" },
38
38
  "widgets:dashboard:goal-progress": { de: "Tilgungsfortschritt", en: "Payoff progress" },
39
+ "widgets:dashboard:demo-cap-users": { de: "Nutzer", en: "Users" },
40
+ "widgets:dashboard:demo-cap-projects": { de: "Projekte", en: "Projects" },
41
+ "widgets:dashboard:help-title": { de: "Erste Schritte", en: "Getting started" },
42
+ "widgets:dashboard:help-heading": { de: "Schritt eins", en: "Step one" },
43
+ "widgets:dashboard:help-body": { de: "Lege einen Schlüssel an.", en: "Create a key." },
44
+ "widgets:dashboard:help-item-1": { de: "Erster Punkt", en: "First item" },
45
+ "widgets:dashboard:help-item-2": { de: "Zweiter Punkt", en: "Second item" },
46
+ "widgets:dashboard:help-hint": { de: "Dauert eine Minute.", en: "Takes a minute." },
39
47
  "widgets:dashboard:filter-region": { de: "Region", en: "Region" },
40
48
  "widgets:dashboard:filter-region-eu": { de: "Europa", en: "Europe" },
41
49
  "widgets:dashboard:filter-region-us": { de: "USA", en: "USA" },
@@ -5,8 +5,29 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
5
5
  ## What it shows
6
6
 
7
7
  - Handler `rateLimit: { per, limit, windowSeconds }`
8
+ - Several buckets on one handler: `rateLimit: [{ per: "user", ... }, { per: "tenant+handler", ... }]`
8
9
  - How L1/L2 from `buildServer` stack with L3
9
10
 
11
+ ## Combined buckets
12
+
13
+ Pass a list to enforce several budgets on one handler, for example a per-caller and a per-tenant cap:
14
+
15
+ ```ts illustration
16
+ r.writeHandler({
17
+ // ...
18
+ rateLimit: [
19
+ { per: "user", limit: 2, windowSeconds: 3600 },
20
+ { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
21
+ ],
22
+ });
23
+ ```
24
+
25
+ Each bucket applies on its own. The check is all-or-nothing: if any bucket is exhausted the call
26
+ answers 429 `rate_limited` for that bucket (the one with the longest wait when several are
27
+ exhausted) and no bucket is charged. Each `per` may appear once; an empty list fails at boot.
28
+ An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
29
+ stays a single declaration.
30
+
10
31
  ## Per-payload limits
11
32
 
12
33
  A public write handler that mails an address typed by the caller is also limited per
@@ -20,5 +20,18 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
20
20
  access: { roles: ["Admin", "User"] },
21
21
  rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
22
22
  });
23
+
24
+ // Combined budget: each caller gets 2 submits per hour and the tenant 3 per hour in
25
+ // total. Both buckets are checked together; a request one bucket rejects charges neither.
26
+ r.writeHandler({
27
+ name: "submit-report",
28
+ schema: z.object({}),
29
+ handler: async () => ({ isSuccess: true as const, data: {} }),
30
+ access: { roles: ["Admin", "User"] },
31
+ rateLimit: [
32
+ { per: "user", limit: 2, windowSeconds: 3600 },
33
+ { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
34
+ ],
35
+ });
23
36
  });
24
37
  }