@cosmicdrift/kumiko-samples 0.351.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 +2 -2
- 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-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- 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/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- 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/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +6 -1
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- 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/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- 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/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- 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/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- 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/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- 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/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- 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/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- 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/seed-migration/README.md +13 -1
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -64,6 +64,9 @@ export type DeliveryChannel = {
|
|
|
64
64
|
// Absent for channels addressed only through `route` (tenant-owned chat
|
|
65
65
|
// targets): deliverToUser skips them without writing a no_address row.
|
|
66
66
|
resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
|
|
67
|
+
// "connection-name": the address is a tenant-chosen connection name (chat webhooks), not
|
|
68
|
+
// personal data, so the attempt log keeps it unmasked. Absent = personal, masked.
|
|
69
|
+
readonly addressKind?: "connection-name";
|
|
67
70
|
render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
|
|
68
71
|
send(
|
|
69
72
|
address: string,
|
|
@@ -118,14 +121,21 @@ export type DeliveryService = {
|
|
|
118
121
|
// payload — collectChannels derives it from the usage's entityName instead.
|
|
119
122
|
export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
|
|
120
123
|
|
|
124
|
+
function hasOptionalHooks(o: object): boolean {
|
|
125
|
+
return (
|
|
126
|
+
(!("render" in o) || o.render === undefined || typeof o.render === "function") &&
|
|
127
|
+
(!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
|
|
128
|
+
(!("addressKind" in o) || o.addressKind === undefined || o.addressKind === "connection-name")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin {
|
|
122
133
|
return (
|
|
123
134
|
typeof o === "object" &&
|
|
124
135
|
o !== null &&
|
|
125
136
|
"mode" in o &&
|
|
126
137
|
DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
|
|
127
|
-
(
|
|
128
|
-
(!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
|
|
138
|
+
hasOptionalHooks(o) &&
|
|
129
139
|
"send" in o &&
|
|
130
140
|
typeof o.send === "function"
|
|
131
141
|
);
|
|
@@ -170,8 +170,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
170
170
|
}
|
|
171
171
|
// Provider resolved — a list()/delete() failure from here IS fail-closed:
|
|
172
172
|
// the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
|
|
173
|
-
// it
|
|
174
|
-
//
|
|
173
|
+
// it and retries up to the stage's attempt cap before abandoning the destroy
|
|
174
|
+
// (list+delete are idempotent, so a retry converges rather than
|
|
175
|
+
// double-deleting or erroring on a missing key).
|
|
175
176
|
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
176
177
|
const keys = await provider.list(prefix);
|
|
177
178
|
const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type EntityDefinition,
|
|
26
26
|
type FeatureRegistrar,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import {
|
|
28
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
29
29
|
import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
|
|
30
30
|
import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
|
|
31
31
|
import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
|
|
@@ -166,17 +166,7 @@ export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof f
|
|
|
166
166
|
) {
|
|
167
167
|
return foldersFeature;
|
|
168
168
|
}
|
|
169
|
-
|
|
170
|
-
throw new Error(
|
|
171
|
-
"createFoldersFeature({ ownership }): ownership.write must not contain a " +
|
|
172
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
173
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
174
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
175
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
176
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
177
|
-
"leave it unset.",
|
|
178
|
-
);
|
|
179
|
-
}
|
|
169
|
+
assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
|
|
180
170
|
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
181
171
|
throw new Error(
|
|
182
172
|
"createFoldersFeature({ parents }): parents must not be an empty array — " +
|
|
@@ -57,6 +57,7 @@ export async function selectStaleDraftsBatch(
|
|
|
57
57
|
/** Re-check staleness after the select→release window so a freshly saved draft is not deleted. */
|
|
58
58
|
export async function isDraftStillStale(
|
|
59
59
|
db: DbConnection,
|
|
60
|
+
tenantId: TenantId,
|
|
60
61
|
id: string,
|
|
61
62
|
olderThanDays: number,
|
|
62
63
|
): Promise<boolean> {
|
|
@@ -64,9 +65,10 @@ export async function isDraftStillStale(
|
|
|
64
65
|
db,
|
|
65
66
|
`SELECT 1 FROM "read_form_drafts"
|
|
66
67
|
WHERE "id" = $1
|
|
67
|
-
AND
|
|
68
|
+
AND "tenant_id" = $2
|
|
69
|
+
AND COALESCE("modified_at", "inserted_at") < now() - ($3::int * interval '1 day')
|
|
68
70
|
LIMIT 1`,
|
|
69
|
-
[id, olderThanDays],
|
|
71
|
+
[id, tenantId, olderThanDays],
|
|
70
72
|
)) as readonly unknown[];
|
|
71
73
|
return rows.length > 0;
|
|
72
74
|
}
|
|
@@ -121,20 +121,24 @@ export function groupStaleDraftIdsByTenant(
|
|
|
121
121
|
// resurrects, PII included. formDraftExecutor.delete() is the event-sourced
|
|
122
122
|
// path; it needs a tenant-scoped db + a SessionUser, so rows are grouped by
|
|
123
123
|
// tenant first (see groupStaleDraftIdsByTenant above).
|
|
124
|
-
async function deleteStaleDraftsBatch(
|
|
124
|
+
export async function deleteStaleDraftsBatch(
|
|
125
125
|
batch: readonly StaleDraftRow[],
|
|
126
126
|
db: DbConnection,
|
|
127
127
|
retentionDays: number,
|
|
128
128
|
log: AppContext["log"],
|
|
129
|
-
): Promise<readonly StaleDraftRow[]> {
|
|
129
|
+
): Promise<{ readonly deletedRows: readonly StaleDraftRow[]; readonly skippedCount: number }> {
|
|
130
130
|
const deletedRows: StaleDraftRow[] = [];
|
|
131
|
+
let skippedCount = 0;
|
|
131
132
|
const byId = new Map(batch.map((row) => [row.id, row]));
|
|
132
133
|
for (const [tenantId, ids] of groupStaleDraftIdsByTenant(batch)) {
|
|
133
134
|
const systemUser = createSystemUser(tenantId);
|
|
134
135
|
const tenantDb = createTenantDb(db, tenantId, "system");
|
|
135
136
|
for (const id of ids) {
|
|
136
137
|
// Race: user may have saved between select and here — skip if no longer stale.
|
|
137
|
-
if (!(await isDraftStillStale(db, id, retentionDays)))
|
|
138
|
+
if (!(await isDraftStillStale(db, tenantId, id, retentionDays))) {
|
|
139
|
+
skippedCount++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
138
142
|
const result = await formDraftExecutor.delete({ id }, systemUser, tenantDb);
|
|
139
143
|
if (result.isSuccess) {
|
|
140
144
|
const row = byId.get(id);
|
|
@@ -146,7 +150,7 @@ async function deleteStaleDraftsBatch(
|
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
|
-
return deletedRows;
|
|
153
|
+
return { deletedRows, skippedCount };
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
export async function cleanupDraftsJob(
|
|
@@ -189,12 +193,20 @@ export async function cleanupDraftsJob(
|
|
|
189
193
|
// entirely for rows with no FileRefs in the blob, the common case.
|
|
190
194
|
// Delete first (with staleness re-check), then release binaries from rows
|
|
191
195
|
// that actually disappeared — never release a draft the user just saved.
|
|
192
|
-
const deletedRows = await deleteStaleDraftsBatch(
|
|
196
|
+
const { deletedRows, skippedCount } = await deleteStaleDraftsBatch(
|
|
197
|
+
batch,
|
|
198
|
+
db,
|
|
199
|
+
retentionDays,
|
|
200
|
+
ctx.log,
|
|
201
|
+
);
|
|
193
202
|
for (const row of deletedRows) {
|
|
194
203
|
await releaseRowFileRefs(row, db, fileProviderResolver, ctx.log);
|
|
195
204
|
}
|
|
196
205
|
deleted += deletedRows.length;
|
|
197
|
-
|
|
206
|
+
// A skipped row is no longer stale, so the next select drops it — a fully
|
|
207
|
+
// skipped batch still makes progress. Only a batch with neither deletes nor
|
|
208
|
+
// skips (all failed) would reselect the same rows forever.
|
|
209
|
+
if (deletedRows.length + skippedCount === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
|
|
198
210
|
}
|
|
199
211
|
ctx.log?.info?.(`[form-draft:cleanup] deleted=${deleted} retentionDays=${retentionDays}`);
|
|
200
212
|
}
|
|
@@ -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.352.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "The legal-pages README imprint example uses a fictional demo identity",
|
|
6
|
+
"detail": "The legal-pages README seeds its imprint example with a fictional demo identity and address instead of a real person's details."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.344.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -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
|
{
|
|
@@ -12,7 +12,7 @@ export type RenderRequest =
|
|
|
12
12
|
| { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
|
|
13
13
|
|
|
14
14
|
export type RenderResponse =
|
|
15
|
-
| { kind: "notification"; html: string }
|
|
15
|
+
| { kind: "notification"; html: string; text?: string }
|
|
16
16
|
| { kind: "mail-html"; html: string; text: string }
|
|
17
17
|
| { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
|
|
18
18
|
| {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Badge, stages track and chips for the simple mail renderer",
|
|
6
|
+
"detail": "Use badge, { stages } and { chips } in the template variables; the notification RenderResponse carries the matching plain text in text."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.344.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -31,12 +31,10 @@ export async function adaptToFoundation(
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
const variables = await resolveNotificationVariables(req, ctx);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
39
|
-
return { kind: "notification", html };
|
|
34
|
+
const input = { template: req.payload.template ?? "", variables, locale: req.payload.locale };
|
|
35
|
+
const html = await renderer.render(input);
|
|
36
|
+
const text = renderer.renderText ? await renderer.renderText(input) : undefined;
|
|
37
|
+
return text === undefined ? { kind: "notification", html } : { kind: "notification", html, text };
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
export type RendererSimpleOptions = {
|