@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/delivery/public-names.ts +1 -1
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/ledger/entity.ts +4 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/tenant/changes.json +7 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
- package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { countWhere, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import * as z from "zod";
|
|
5
6
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
|
|
6
7
|
import { jobRunsTable } from "../job-run-table.js";
|
|
7
8
|
|
|
8
9
|
const KMS_POOL_CONCURRENCY = 4;
|
|
10
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
9
11
|
|
|
10
12
|
const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
|
|
11
13
|
|
|
@@ -23,6 +25,31 @@ async function decryptRunRow<T extends Record<string, unknown>>(row: T): Promise
|
|
|
23
25
|
return result;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
type JobRunStatus = z.infer<typeof jobRunStatusSchema>;
|
|
29
|
+
|
|
30
|
+
function buildListWhere(payload: {
|
|
31
|
+
readonly jobName?: string | undefined;
|
|
32
|
+
readonly status?: JobRunStatus | undefined;
|
|
33
|
+
readonly filters?: readonly { readonly value: readonly JobRunStatus[] }[] | undefined;
|
|
34
|
+
}): WhereObject {
|
|
35
|
+
const where: WhereObject = {};
|
|
36
|
+
if (payload.jobName) where["jobName"] = payload.jobName;
|
|
37
|
+
const statuses = payload.filters?.[0]?.value ?? (payload.status ? [payload.status] : []);
|
|
38
|
+
if (statuses.length === 1) where["status"] = statuses[0];
|
|
39
|
+
else if (statuses.length > 1) where["status"] = { in: statuses };
|
|
40
|
+
return where;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseOffsetCursor(cursor: string | undefined): number {
|
|
44
|
+
const offset = cursor ? Number(decodeCursor(cursor)) : 0;
|
|
45
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
46
|
+
throw new ValidationError({
|
|
47
|
+
fields: [{ path: "cursor", code: "invalid_cursor", i18nKey: "jobs.errors.invalidCursor" }],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return offset;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
export const listQuery = defineQueryHandler({
|
|
27
54
|
name: "list",
|
|
28
55
|
description:
|
|
@@ -42,6 +69,7 @@ export const listQuery = defineQueryHandler({
|
|
|
42
69
|
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
43
70
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
44
71
|
limit: z.number().optional(),
|
|
72
|
+
cursor: z.string().optional(),
|
|
45
73
|
totalCount: z.boolean().optional(),
|
|
46
74
|
}),
|
|
47
75
|
access: { roles: ["SystemAdmin"] },
|
|
@@ -54,17 +82,22 @@ export const listQuery = defineQueryHandler({
|
|
|
54
82
|
const db = ctx.systemDb.unsafeRaw(
|
|
55
83
|
"cross-tenant job monitoring: SystemAdmin lists job runs of every tenant",
|
|
56
84
|
);
|
|
57
|
-
const where
|
|
58
|
-
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
59
|
-
const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
|
|
60
|
-
const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
|
|
61
|
-
if (statuses.length === 1) where["status"] = statuses[0];
|
|
62
|
-
else if (statuses.length > 1) where["status"] = { in: statuses };
|
|
85
|
+
const where = buildListWhere(query.payload);
|
|
63
86
|
const sortColumn = query.payload.sort ?? "startedAt";
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
const limit = query.payload.limit ?? DEFAULT_PAGE_SIZE;
|
|
88
|
+
const offset = parseOffsetCursor(query.payload.cursor);
|
|
89
|
+
const direction = query.payload.sortDirection ?? "desc";
|
|
90
|
+
// selectMany has no OFFSET: read through the end of the page plus one probe
|
|
91
|
+
// row, then slice. `id` as tiebreaker keeps pages stable for non-unique sorts.
|
|
92
|
+
const fetched = await selectMany(db, jobRunsTable, where, {
|
|
93
|
+
orderBy: [
|
|
94
|
+
{ col: sortColumn, direction },
|
|
95
|
+
{ col: "id", direction },
|
|
96
|
+
],
|
|
97
|
+
limit: offset + limit + 1,
|
|
67
98
|
});
|
|
99
|
+
const hasMore = fetched.length > offset + limit;
|
|
100
|
+
const rows = fetched.slice(offset, offset + limit);
|
|
68
101
|
// countWhere reruns the SAME `where` without the limit — `rows.length` is
|
|
69
102
|
// capped at the page size and would silently undercount the total.
|
|
70
103
|
const total =
|
|
@@ -72,7 +105,7 @@ export const listQuery = defineQueryHandler({
|
|
|
72
105
|
// payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
|
|
73
106
|
return {
|
|
74
107
|
rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
|
|
75
|
-
nextCursor: null,
|
|
108
|
+
nextCursor: hasMore ? encodeCursor(String(offset + limit)) : null,
|
|
76
109
|
...(total !== undefined && { total }),
|
|
77
110
|
};
|
|
78
111
|
},
|
|
@@ -36,6 +36,7 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
36
36
|
"jobs.detail.logs": { en: "Logs" },
|
|
37
37
|
"jobs.detail.retry": { en: "Retry" },
|
|
38
38
|
"jobs.trigger.title": { en: "Run a job" },
|
|
39
|
+
"jobs.errors.invalidCursor": { en: "The pagination cursor is invalid." },
|
|
39
40
|
"jobs.trigger.submit": { en: "Run" },
|
|
40
41
|
"jobs.errors.unknownJob": { en: "Unknown job." },
|
|
41
42
|
"jobs.errors.notManual": {
|
|
@@ -94,12 +94,14 @@ export const transactionEntity = createEntity({
|
|
|
94
94
|
// Optional business-object reference (e.g. a lease contract) so entries can
|
|
95
95
|
// be filtered by what they're about, not just grepped out of `reference`.
|
|
96
96
|
subjectType: createTextField({
|
|
97
|
+
minLength: 1,
|
|
97
98
|
maxLength: 64,
|
|
98
99
|
personal: false,
|
|
99
100
|
reason: "technical_reference",
|
|
100
101
|
filterable: true,
|
|
101
102
|
}),
|
|
102
103
|
subjectId: createTextField({
|
|
104
|
+
minLength: 1,
|
|
103
105
|
maxLength: 128,
|
|
104
106
|
personal: false,
|
|
105
107
|
reason: "technical_reference",
|
|
@@ -147,12 +149,14 @@ export const scheduleEntity = createEntity({
|
|
|
147
149
|
// Confirmed periods inherit this from the schedule (see
|
|
148
150
|
// confirm-schedule-period.write.ts) rather than repeating it per period.
|
|
149
151
|
subjectType: createTextField({
|
|
152
|
+
minLength: 1,
|
|
150
153
|
maxLength: 64,
|
|
151
154
|
personal: false,
|
|
152
155
|
reason: "technical_reference",
|
|
153
156
|
filterable: true,
|
|
154
157
|
}),
|
|
155
158
|
subjectId: createTextField({
|
|
159
|
+
minLength: 1,
|
|
156
160
|
maxLength: 128,
|
|
157
161
|
personal: false,
|
|
158
162
|
reason: "technical_reference",
|
|
@@ -28,8 +28,14 @@ export const createTransactionPayloadSchema = z
|
|
|
28
28
|
status: z.enum(TRANSACTION_STATUS).optional(),
|
|
29
29
|
lines: z.array(postingSchema).min(2),
|
|
30
30
|
// Business-object reference (e.g. a lease contract) this entry is about.
|
|
31
|
-
subjectType: z.string().max(64).optional(),
|
|
32
|
-
subjectId: z.string().max(128).optional(),
|
|
31
|
+
subjectType: z.string().min(1).max(64).optional(),
|
|
32
|
+
subjectId: z.string().min(1).max(128).optional(),
|
|
33
|
+
})
|
|
34
|
+
// Ids are only unique within their type, so a lone subjectId would match
|
|
35
|
+
// bookings of a different object type.
|
|
36
|
+
.refine((p) => (p.subjectType === undefined) === (p.subjectId === undefined), {
|
|
37
|
+
message: "subjectType and subjectId must be set together",
|
|
38
|
+
path: ["subjectId"],
|
|
33
39
|
})
|
|
34
40
|
.refine((p) => sumIsZero(p.lines), {
|
|
35
41
|
message: "Transaction must balance: Σ of posting amounts must equal 0",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A rate-limited page request answers 429 with Retry-After instead of 503 or an unbranded page",
|
|
6
|
+
"detail": "Managed pages and sitemap answer 429 when rate limited, agent catalog checks the deny list, draft cleanup continues past skipped rows\nA rate-limited managed-page request now answers 429 with `Retry-After` instead of 503 or an unbranded page, and `sitemap.xml` and `llms.txt` answer 429 instead of silently dropping the managed-page entries. `buildToolCatalog` throws when its manifest was built with `denyQns` that the catalog options lack. The form-draft cleanup no longer stops when a whole batch was re-saved by users in the meantime."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import {
|
|
4
5
|
type BrandingTokens,
|
|
5
6
|
cachedSecurePageResponse,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
renderSafeMarkdown,
|
|
8
9
|
wrapInLayout,
|
|
9
10
|
} from "../page-render/index.js";
|
|
10
|
-
import type
|
|
11
|
+
import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.js";
|
|
11
12
|
import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.js";
|
|
12
13
|
import { createBrandingQuery } from "./handlers/branding.query.js";
|
|
13
14
|
import { bySlugQuery } from "./handlers/by-slug.query.js";
|
|
@@ -51,16 +52,18 @@ function brandingRevisionSeed(branding: BrandingTokens): string {
|
|
|
51
52
|
]);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
55
|
+
// A query failure or malformed result degrades to the unbranded default
|
|
56
|
+
// (branding is decoration, not a hard dependency of the page render). A rate
|
|
57
|
+
// limit is rethrown so the route answers 429 instead of serving (and caching)
|
|
58
|
+
// an unbranded page.
|
|
57
59
|
async function readBrandingViaSystemQuery(
|
|
58
60
|
systemQuery: SystemQueryFn,
|
|
59
61
|
tenantId: string,
|
|
60
62
|
): Promise<BrandingTokens> {
|
|
61
63
|
try {
|
|
62
64
|
return coerceBranding(await systemQuery(BRANDING_QUERY_QN, {}, tenantId));
|
|
63
|
-
} catch {
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof RateLimitError) throw error;
|
|
64
67
|
return EMPTY_BRANDING;
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -207,12 +210,22 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
207
210
|
// von resolverTrust: "authoritative" zurecht abgelehnt; siehe
|
|
208
211
|
// systemQuery's Doc in http-route.ts). Branding ist Deko → ein
|
|
209
212
|
// Fehlschlag degradiert zum unbranded Default, blockt nie die Page.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
let pageSettled: { ok: true; data: BySlugQueryResult } | { ok: false };
|
|
214
|
+
let brandingResult: BrandingTokens;
|
|
215
|
+
try {
|
|
216
|
+
[pageSettled, brandingResult] = await Promise.all([
|
|
217
|
+
systemQuery(BY_SLUG_QN, { slug, lang }, tenantId)
|
|
218
|
+
.then((data) => ({ ok: true as const, data: data as BySlugQueryResult }))
|
|
219
|
+
.catch((error: unknown) => {
|
|
220
|
+
if (error instanceof RateLimitError) throw error;
|
|
221
|
+
return { ok: false as const };
|
|
222
|
+
}),
|
|
223
|
+
readBrandingViaSystemQuery(systemQuery, tenantId),
|
|
224
|
+
]);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
216
229
|
if (!pageSettled.ok) return c.text("page unavailable", 503);
|
|
217
230
|
|
|
218
231
|
const data = pageSettled.data;
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type EntityDefinition,
|
|
21
21
|
type FeatureRegistrar,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
-
import {
|
|
23
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
24
24
|
import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants.js";
|
|
25
25
|
import { createNoteEntryEntity, noteMentionEntity } from "./entity.js";
|
|
26
26
|
import { createNoteEntryExecutor } from "./executor.js";
|
|
@@ -123,17 +123,7 @@ export function createNotesHistoryFeature(
|
|
|
123
123
|
) {
|
|
124
124
|
return notesHistoryFeature;
|
|
125
125
|
}
|
|
126
|
-
|
|
127
|
-
throw new Error(
|
|
128
|
-
"createNotesHistoryFeature({ ownership }): ownership.write must not contain a " +
|
|
129
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
130
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
131
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
132
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
133
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
134
|
-
"leave it unset.",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
126
|
+
assertNoWhereRuleInOwnershipWrite("createNotesHistoryFeature", opts.ownership?.write);
|
|
137
127
|
// A mount that accepts no parents at all can never take a note write —
|
|
138
128
|
// that's a config mistake, not a valid allowlist. Omit `parents` instead
|
|
139
129
|
// of passing an empty array.
|
|
@@ -4,10 +4,25 @@ import {
|
|
|
4
4
|
type ProjectionListScreenDefinition,
|
|
5
5
|
type SecretMintScreenDefinition,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import {
|
|
7
|
+
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
|
+
import { fieldOptionLabelKeyPrefix } from "@cosmicdrift/kumiko-headless";
|
|
9
|
+
import {
|
|
10
|
+
PAT_FEATURE,
|
|
11
|
+
PAT_MINT_SCREEN_ID,
|
|
12
|
+
PAT_SCREEN_ID,
|
|
13
|
+
PatHandlers,
|
|
14
|
+
PatQueries,
|
|
15
|
+
} from "./constants.js";
|
|
8
16
|
import type { PatScopeConfig } from "./scopes.js";
|
|
9
17
|
|
|
10
18
|
const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
|
|
19
|
+
// Same keys the mint form's `scopes` multiSelect options resolve through
|
|
20
|
+
// (see patScopeOptionTranslations), so list and form show identical labels.
|
|
21
|
+
const PAT_SCOPE_OPTION_KEY_PREFIX = fieldOptionLabelKeyPrefix(
|
|
22
|
+
PAT_FEATURE,
|
|
23
|
+
ACTION_FORM_ENTITY,
|
|
24
|
+
"scopes",
|
|
25
|
+
);
|
|
11
26
|
|
|
12
27
|
// Both screens render only for tenants whose tier includes the feature; the
|
|
13
28
|
// availability query is rejected by the feature gate otherwise.
|
|
@@ -29,11 +44,17 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
29
44
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
30
45
|
columns: [
|
|
31
46
|
{ field: "name", label: i18nKey("pat.list.col.name") },
|
|
32
|
-
{ field: "prefix", label: i18nKey("pat.list.col.prefix") },
|
|
33
|
-
{
|
|
47
|
+
{ field: "prefix", label: i18nKey("pat.list.col.prefix"), sortable: false },
|
|
48
|
+
{
|
|
49
|
+
field: "scopes",
|
|
50
|
+
label: i18nKey("pat.list.col.scopes"),
|
|
51
|
+
sortable: false,
|
|
52
|
+
renderer: { format: "enumOption", keyPrefix: PAT_SCOPE_OPTION_KEY_PREFIX },
|
|
53
|
+
},
|
|
34
54
|
{
|
|
35
55
|
field: "status",
|
|
36
56
|
label: i18nKey("pat.list.col.status"),
|
|
57
|
+
sortable: false,
|
|
37
58
|
renderer: { format: "enumOption", keyPrefix: PAT_STATUS_OPTION_KEY_PREFIX },
|
|
38
59
|
},
|
|
39
60
|
{
|
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
type FeatureDefinition,
|
|
6
6
|
SYSTEM_TENANT_ID,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
9
|
import { LEGAL_ROUTES } from "../legal-pages/index.js";
|
|
9
10
|
import { cachedSecurePageResponse } from "../page-render/index.js";
|
|
10
|
-
import type
|
|
11
|
+
import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.js";
|
|
11
12
|
import { SEO_CONFIG_KEYS, SEO_DEFAULT_PATHS } from "./constants.js";
|
|
12
13
|
import { seoConfigQuery } from "./handlers/seo-config.query.js";
|
|
13
14
|
import { buildLlmsTxt } from "./llms-txt.js";
|
|
@@ -125,7 +126,9 @@ async function gatherEntries(
|
|
|
125
126
|
lastmod: page.updatedAt,
|
|
126
127
|
});
|
|
127
128
|
}
|
|
128
|
-
} catch {
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// A rate limit must not look like "pages removed" to a crawler.
|
|
131
|
+
if (error instanceof RateLimitError) throw error;
|
|
129
132
|
// managed-pages unreachable/not mounted — degrade to callback-only entries.
|
|
130
133
|
}
|
|
131
134
|
}
|
|
@@ -188,7 +191,13 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
188
191
|
anonymous: true,
|
|
189
192
|
handler: async (c, { systemQuery }) => {
|
|
190
193
|
const { origin, host } = requestHost(c);
|
|
191
|
-
|
|
194
|
+
let entries: SitemapEntry[];
|
|
195
|
+
try {
|
|
196
|
+
entries = await gatherEntries(opts, systemQuery, origin, host);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
192
201
|
const xml = buildSitemapXml(entries);
|
|
193
202
|
const etag = computeRevisionEtag([host, xml]);
|
|
194
203
|
return cachedSecurePageResponse(c.req.raw, {
|
|
@@ -209,10 +218,17 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
209
218
|
const tenantId = opts.managedPages
|
|
210
219
|
? ((await opts.managedPages.resolveApexTenant(host)) ?? SYSTEM_TENANT_ID)
|
|
211
220
|
: SYSTEM_TENANT_ID;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
let entries: SitemapEntry[];
|
|
222
|
+
let seoConfig: SeoConfigValues;
|
|
223
|
+
try {
|
|
224
|
+
[entries, seoConfig] = await Promise.all([
|
|
225
|
+
gatherEntries(opts, systemQuery, origin, host),
|
|
226
|
+
readSeoConfig(systemQuery, tenantId),
|
|
227
|
+
]);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
216
232
|
const sections =
|
|
217
233
|
entries.length > 0
|
|
218
234
|
? [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// Boot validation rejects `{ kind: "where" }` in access.write too (fw#2626);
|
|
4
|
+
// failing at factory time names the option instead of an entity scope.
|
|
5
|
+
export function assertNoWhereRuleInOwnershipWrite(
|
|
6
|
+
factoryName: string,
|
|
7
|
+
writeOwnership: OwnershipMap | undefined,
|
|
8
|
+
): void {
|
|
9
|
+
// skip: no write ownership declared, nothing to validate
|
|
10
|
+
if (!writeOwnership) return;
|
|
11
|
+
const hasWhereRule = Object.values(writeOwnership).some(
|
|
12
|
+
(rule) => rule !== "all" && rule.kind === "where",
|
|
13
|
+
);
|
|
14
|
+
// skip: no where-rule present, the option is valid
|
|
15
|
+
if (!hasWhereRule) return;
|
|
16
|
+
throw new Error(
|
|
17
|
+
`${factoryName}({ ownership }): ownership.write must not contain a ` +
|
|
18
|
+
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
19
|
+
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
20
|
+
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
21
|
+
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
22
|
+
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
23
|
+
"leave it unset.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { collectErasureFailure, throwIfErasureFailed } from "./assert-erased.js";
|
|
2
|
+
export { assertNoWhereRuleInOwnershipWrite } from "./assert-no-where-rule-in-ownership-write.js";
|
|
2
3
|
export {
|
|
3
4
|
type ChunkedMigrationOptions,
|
|
4
5
|
type ChunkedMigrationResult,
|
|
@@ -14,7 +15,6 @@ export { decryptStoredPii } from "./decrypt-stored-pii.js";
|
|
|
14
15
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write.js";
|
|
15
16
|
export { entitiesOf } from "./entities-of.js";
|
|
16
17
|
export { isWithinGracePeriod } from "./grace-period.js";
|
|
17
|
-
export { hasWhereRule } from "./has-where-rule.js";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
|
|
19
19
|
export { isTenantDb } from "./is-tenant-db.js";
|
|
20
20
|
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.js";
|
|
@@ -25,6 +25,7 @@ export {
|
|
|
25
25
|
parentRowIsVisible,
|
|
26
26
|
} from "./parent-visibility.js";
|
|
27
27
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
28
|
+
export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
|
|
28
29
|
export {
|
|
29
30
|
redactBotTokens,
|
|
30
31
|
redactEmailAddresses,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
|
|
3
|
+
export function rateLimitedTextResponse(error: RateLimitError): Response {
|
|
4
|
+
return new Response("rate limited", {
|
|
5
|
+
status: 429,
|
|
6
|
+
headers: {
|
|
7
|
+
"content-type": "text/plain; charset=utf-8",
|
|
8
|
+
"retry-after": String(error.details.retryAfterSeconds),
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type EntityDefinition,
|
|
28
28
|
type FeatureRegistrar,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
-
import {
|
|
30
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
31
31
|
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants.js";
|
|
32
32
|
import { createTagAssignmentEntity, tagEntity } from "./entity.js";
|
|
33
33
|
import { createTagAssignmentExecutor } from "./executor.js";
|
|
@@ -201,17 +201,7 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
|
|
|
201
201
|
"admissible as a host instead.",
|
|
202
202
|
);
|
|
203
203
|
}
|
|
204
|
-
|
|
205
|
-
throw new Error(
|
|
206
|
-
"createTagsFeature({ ownership }): ownership.write must not contain a " +
|
|
207
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
208
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
209
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
210
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
211
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
212
|
-
"leave it unset.",
|
|
213
|
-
);
|
|
214
|
-
}
|
|
204
|
+
assertNoWhereRuleInOwnershipWrite("createTagsFeature", opts.ownership?.write);
|
|
215
205
|
const access = resolveAccess(opts);
|
|
216
206
|
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
217
207
|
registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Tenant names are encrypted under the tenant record and crypto-shredded when the tenant is destroyed",
|
|
6
|
+
"detail": "Tenant names are PII encrypted under the tenant record and crypto-shredded on tenant destroy\n`tenant.name` is now `personal: { of: \"id\" }`, so it is stored as `kumiko-pii:` ciphertext in events and in `read_tenants`. Signup no longer writes the email into the tenant name; it uses the generated tenant key. The tenant destroy job erases the tenant record key and purges the tenant's search documents. The tenant queries (`me`, memberships, tenant-directory, user list, cap-overview, tenants-missing-profile) decrypt the name, and the tenant list sorts by `key` because encrypted names are not sortable.",
|
|
7
|
+
"migration": "After the bump run the PII backfill (backfillEventPiiEncryption), then rebuild the read_tenants projection; the backfill only encrypts event payloads. Raw reads of read_tenants.name in apps now return ciphertext and must go through decryptStoredPii or the tenant query handlers. Tenants that were already destroyed (read_tenants.destroyed_at set) get the erased sentinel instead of a fresh record key."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.341.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { decryptStoredPii, mapWithConcurrency } from "../shared/index.js";
|
|
2
|
+
|
|
3
|
+
// Bounded like tenant:query:members: each decrypt hits the KMS adapter's small pool.
|
|
4
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
5
|
+
|
|
6
|
+
// Raw reads of read_tenants return the stored column, which is ciphertext
|
|
7
|
+
// under an active KMS; executor reads (crud.list/detail) decrypt on their own.
|
|
8
|
+
export async function decryptTenantNames<T extends { readonly name?: unknown }>(
|
|
9
|
+
rows: readonly T[],
|
|
10
|
+
fallbackRequestId: string,
|
|
11
|
+
): Promise<T[]> {
|
|
12
|
+
return mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, async (row) =>
|
|
13
|
+
typeof row.name === "string"
|
|
14
|
+
? { ...row, name: await decryptStoredPii(row.name, "name", fallbackRequestId) }
|
|
15
|
+
: row,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -2,6 +2,7 @@ import { fetchOne } 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 { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
5
6
|
import { tenantTable } from "../schema/tenant.js";
|
|
6
7
|
|
|
7
8
|
// Direct query — query handlers don't have a tenant-crud handle. A direct
|
|
@@ -33,6 +34,8 @@ export const meQuery = defineQueryHandler({
|
|
|
33
34
|
}
|
|
34
35
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
35
36
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
36
|
-
|
|
37
|
+
if (row === undefined) return null;
|
|
38
|
+
const [decrypted] = await decryptTenantNames([row], "tenant:me");
|
|
39
|
+
return decrypted ?? null;
|
|
37
40
|
},
|
|
38
41
|
});
|
|
@@ -3,6 +3,7 @@ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/e
|
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
6
7
|
import { tenantMembershipsTable } from "../membership-table.js";
|
|
7
8
|
import { tenantTable } from "../schema/tenant.js";
|
|
8
9
|
|
|
@@ -32,9 +33,12 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
32
33
|
// prefix would be indistinguishable). A single IN-batch over all
|
|
33
34
|
// tenantIds instead of fetchOne per membership (#324).
|
|
34
35
|
type TenantRow = { id: unknown; name?: unknown; key?: unknown; isEnabled?: unknown };
|
|
35
|
-
const tenants = await
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const tenants = await decryptTenantNames(
|
|
37
|
+
await selectMany<TenantRow>(db, tenantTable, {
|
|
38
|
+
id: rows.map((row) => row["tenantId"]),
|
|
39
|
+
}),
|
|
40
|
+
"tenant:memberships",
|
|
41
|
+
);
|
|
38
42
|
const tenantById = new Map<unknown, TenantRow>(tenants.map((t) => [t.id, t]));
|
|
39
43
|
|
|
40
44
|
return rows
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
9
10
|
import { tenantTable } from "../schema/tenant.js";
|
|
10
11
|
import { isSystemAdmin } from "./is-system-admin.js";
|
|
11
12
|
|
|
@@ -48,7 +49,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
48
49
|
tenants = await selectMany(db, tenantTable, undefined, {
|
|
49
50
|
limit: scanLimit,
|
|
50
51
|
orderBy: [
|
|
51
|
-
{ col: "
|
|
52
|
+
{ col: "key", direction: "asc" },
|
|
52
53
|
{ col: "id", direction: "asc" },
|
|
53
54
|
],
|
|
54
55
|
});
|
|
@@ -57,10 +58,15 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
57
58
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
58
59
|
tenants = row ? [row] : [];
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// name is ciphertext at rest, so the SQL page is keyed by the plaintext
|
|
62
|
+
// `key`; label order and search happen in memory after decrypting.
|
|
63
|
+
const decrypted = await decryptTenantNames(tenants, "tenant:tenant-directory");
|
|
64
|
+
const labeled = decrypted
|
|
65
|
+
.map((tenant) => {
|
|
66
|
+
const id = String(tenant.id);
|
|
67
|
+
return { id, label: typeof tenant.name === "string" ? tenant.name : id };
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
64
70
|
const needle = search?.toLowerCase();
|
|
65
71
|
const rows =
|
|
66
72
|
needle === undefined
|
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
TenantHandlers,
|
|
8
8
|
TenantQueries,
|
|
9
9
|
} from "./constants.js";
|
|
10
|
+
export { decryptTenantNames } from "./decrypt-tenant-names.js";
|
|
10
11
|
export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
|
|
11
12
|
export type { InvitationStatus } from "./invitation-table.js";
|
|
12
13
|
export {
|
|
@@ -28,19 +28,17 @@ export const tenantEntity = createEntity({
|
|
|
28
28
|
key: createTextField({
|
|
29
29
|
required: true,
|
|
30
30
|
maxLength: 50,
|
|
31
|
+
sortable: true,
|
|
31
32
|
personal: false,
|
|
32
33
|
reason: "technical_reference",
|
|
33
34
|
}),
|
|
34
|
-
//
|
|
35
|
-
// same class as the `company.legalName` example in the boot-validator's
|
|
36
|
-
// own PII-heuristic doc-comment.
|
|
35
|
+
// Subject is the tenant record itself: tenant events live in the writer's (system) tenant stream, so `personal: "tenant"` would not resolve to this tenant.
|
|
37
36
|
name: createTextField({
|
|
38
37
|
required: true,
|
|
39
38
|
maxLength: 200,
|
|
40
39
|
searchable: true,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
reason: "is_business_data",
|
|
40
|
+
personal: { of: "id" },
|
|
41
|
+
find: "none",
|
|
44
42
|
}),
|
|
45
43
|
isEnabled: createBooleanField({ default: true, filterable: true }),
|
|
46
44
|
// Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
|
|
@@ -43,7 +43,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
|
|
|
43
43
|
entityId: "id",
|
|
44
44
|
},
|
|
45
45
|
],
|
|
46
|
-
defaultSort: { field: "
|
|
46
|
+
defaultSort: { field: "key", dir: "asc" },
|
|
47
47
|
searchable: true,
|
|
48
48
|
access: { roles: ["SystemAdmin"] },
|
|
49
49
|
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant handover claims fail with transfer_graph_too_deep only when rows exist beyond the last allowed hop",
|
|
6
|
+
"detail": "Tenant handover only fails with `transfer_graph_too_deep` when rows really hang below the fifth hop, and failed workflow runs report the suspended step\nA claim used to fail whenever the last round's entity type had outgoing edges, even with no rows behind them, so a chain of exactly five hops with an unrestricted `parentRef` entity registered was rejected. `workflow.run-failed` carries the real `stepIndex` for the unsupported-suspension case instead of always 0."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.321.0",
|
|
4
10
|
"type": "improvement",
|