@cosmicdrift/kumiko-samples 0.400.0 → 0.402.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 +177 -42
- package/packages/bundled-features/src/agent-tools/changes.json +19 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +8 -15
- package/packages/bundled-features/src/agent-tools/types.ts +11 -10
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +2 -2
- package/packages/bundled-features/src/documents/entity.ts +7 -1
- package/packages/bundled-features/src/documents/feature.ts +34 -5
- package/packages/bundled-features/src/documents/handlers/import.write.ts +1 -1
- package/packages/bundled-features/src/documents/handlers/link.write.ts +2 -0
- package/packages/bundled-features/src/documents/i18n.ts +14 -0
- package/packages/bundled-features/src/documents/index.ts +1 -0
- package/packages/bundled-features/src/documents/tab.ts +2 -2
- package/packages/bundled-features/src/documents/target-write-check.ts +9 -3
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +3 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +5 -5
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +5 -7
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +9 -1
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +66 -12
- package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +1 -1
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -2
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +2 -19
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +2 -2
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +0 -1
- package/packages/bundled-features/src/readiness/changes.json +6 -0
- package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +2 -1
- package/packages/bundled-features/src/readiness/run-view.ts +4 -5
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +2 -2
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +18 -1
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -2
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +5 -3
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +41 -33
- package/packages/bundled-features/src/user-data-rights/boot-rules.ts +19 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -0
- package/packages/bundled-features/src/user-data-rights/web/request-deletion-screen.tsx +20 -18
- package/samples/apps/use-all-bundled/src/app/collection-labels-feature.ts +3 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/relations/src/feature.ts +13 -3
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
DOCUMENT_LINK_ENTITY,
|
|
24
24
|
DOCUMENTS_FEATURE_NAME,
|
|
25
25
|
} from "./constants.ts";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createDocumentEntity,
|
|
28
|
+
createDocumentLinkEntity,
|
|
29
|
+
type DocumentLinkReadMode,
|
|
30
|
+
} from "./entity.ts";
|
|
27
31
|
import { createDocumentExecutors } from "./executor.ts";
|
|
28
32
|
import { createImportDocumentHandler } from "./handlers/import.write.ts";
|
|
29
33
|
import { createLinkDocumentHandler, createUnlinkDocumentHandler } from "./handlers/link.write.ts";
|
|
@@ -38,14 +42,23 @@ import { createDocumentWriteGuard } from "./write-guard.ts";
|
|
|
38
42
|
export type DocumentsFeatureOptions = {
|
|
39
43
|
/** Allowed document categories; they become the options of the category field. */
|
|
40
44
|
readonly categories: readonly [string, ...string[]];
|
|
41
|
-
/** Entities a document can be linked to.
|
|
45
|
+
/** Entities a document can be linked to. Linking needs write access to the target: its update handler, or its entity write roles when it has none. */
|
|
42
46
|
readonly linkTargets: readonly [string, ...string[]];
|
|
47
|
+
/** Display label per category (de, en); a category without one shows its raw value. */
|
|
48
|
+
readonly categoryLabels?: Readonly<Record<string, { readonly de: string; readonly en: string }>>;
|
|
43
49
|
/** Files saved into these fields become documents automatically. */
|
|
44
50
|
readonly capture?: readonly DocumentCaptureRule[];
|
|
45
51
|
/** Retention of the document rows, in the entity's own retention vocabulary. */
|
|
46
52
|
readonly retention?: EntityDefinition["retention"];
|
|
47
53
|
/** Row-level read rule on the document, AND-ed with the link and file-owner gate. */
|
|
48
54
|
readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
|
|
55
|
+
/**
|
|
56
|
+
* "anyLink" (default): one readable linked record shows the document. "allLinks": the viewer
|
|
57
|
+
* must read every record it is linked to, so a link to a shared record (a contact) does not
|
|
58
|
+
* open a document that also hangs off a record of another team. The file-owner check applies
|
|
59
|
+
* either way.
|
|
60
|
+
*/
|
|
61
|
+
readonly linkReadMode?: DocumentLinkReadMode;
|
|
49
62
|
/**
|
|
50
63
|
* Who may use the document handlers. One rule applies to read, write and delete; the split form
|
|
51
64
|
* sets them apart. Default TenantAdmin and TenantMember; a missing delete follows write.
|
|
@@ -84,11 +97,14 @@ function resolveAccess(access: AccessRule | DocumentsAccess | undefined): Resolv
|
|
|
84
97
|
return { read: access.read ?? DEFAULT_DOCUMENT_ACCESS, write, delete: access.delete ?? write };
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
function categoryOptionLabels(
|
|
100
|
+
function categoryOptionLabels(
|
|
101
|
+
categories: readonly string[],
|
|
102
|
+
labels: DocumentsFeatureOptions["categoryLabels"],
|
|
103
|
+
) {
|
|
88
104
|
return Object.fromEntries(
|
|
89
105
|
categories.map((category) => [
|
|
90
106
|
`documents:entity:filed-document:field:category:option:${category}`,
|
|
91
|
-
{ en: category, de: category },
|
|
107
|
+
labels?.[category] ?? { en: category, de: category },
|
|
92
108
|
]),
|
|
93
109
|
);
|
|
94
110
|
}
|
|
@@ -103,6 +119,7 @@ function registerDocuments(
|
|
|
103
119
|
categories: opts.categories,
|
|
104
120
|
adminRoles: opts.adminRoles ?? DEFAULT_ADMIN_ROLES,
|
|
105
121
|
...(opts.readAccess !== undefined && { readAccess: opts.readAccess }),
|
|
122
|
+
...(opts.linkReadMode !== undefined && { linkReadMode: opts.linkReadMode }),
|
|
106
123
|
...(opts.retention !== undefined && { retention: opts.retention }),
|
|
107
124
|
});
|
|
108
125
|
const linkEntity = createDocumentLinkEntity(linkTargets);
|
|
@@ -123,6 +140,7 @@ function registerDocuments(
|
|
|
123
140
|
access: access.write,
|
|
124
141
|
description:
|
|
125
142
|
"Files a new document from an uploaded file; it stays invisible to everyone but the admin roles until it is linked to a record.",
|
|
143
|
+
agent: { risk: "mid" },
|
|
126
144
|
}),
|
|
127
145
|
);
|
|
128
146
|
const writeGuard = createDocumentWriteGuard(executors, opts.adminRoles ?? DEFAULT_ADMIN_ROLES);
|
|
@@ -131,6 +149,7 @@ function registerDocuments(
|
|
|
131
149
|
access: access.write,
|
|
132
150
|
description:
|
|
133
151
|
"Edits a document's title, category and dates from the entity-edit envelope; it does not change what the document is linked to.",
|
|
152
|
+
agent: { risk: "mid" },
|
|
134
153
|
}),
|
|
135
154
|
);
|
|
136
155
|
r.writeHandler(
|
|
@@ -185,7 +204,10 @@ function registerDocuments(
|
|
|
185
204
|
|
|
186
205
|
r.screen(createDocumentListScreen(access.read, opts.categories));
|
|
187
206
|
r.translations({
|
|
188
|
-
keys: {
|
|
207
|
+
keys: {
|
|
208
|
+
...DOCUMENTS_FEATURE_I18N,
|
|
209
|
+
...categoryOptionLabels(opts.categories, opts.categoryLabels),
|
|
210
|
+
},
|
|
189
211
|
});
|
|
190
212
|
}
|
|
191
213
|
|
|
@@ -193,5 +215,12 @@ export function createDocumentsFeature(opts: DocumentsFeatureOptions) {
|
|
|
193
215
|
if (opts.expiringWithinDays !== undefined && opts.expiringWithinDays < 0) {
|
|
194
216
|
throw new Error("createDocumentsFeature({ expiringWithinDays }) must not be negative.");
|
|
195
217
|
}
|
|
218
|
+
for (const category of Object.keys(opts.categoryLabels ?? {})) {
|
|
219
|
+
if (!opts.categories.includes(category)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`createDocumentsFeature({ categoryLabels }) names "${category}", which is not in categories.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
196
225
|
return defineFeature(DOCUMENTS_FEATURE_NAME, (r) => registerDocuments(r, opts));
|
|
197
226
|
}
|
|
@@ -97,7 +97,7 @@ export function createImportDocumentHandler(args: ImportArgs) {
|
|
|
97
97
|
name: "import",
|
|
98
98
|
schema: importDocumentPayloadSchema,
|
|
99
99
|
access: { roles: [SYSTEM_ROLE] },
|
|
100
|
-
agent: { expose: false },
|
|
100
|
+
agent: { expose: false, risk: "high" },
|
|
101
101
|
description:
|
|
102
102
|
"Creates a document with a caller-chosen id from an existing file, links it to the given records and optionally leaves it deleted; a repeat call with the same id changes nothing, and an id already used by another record fails. The document's createdAt is the time of the import; issuedAt carries the business date. Reserved for the system user, meant for data migrations.",
|
|
103
103
|
handler: async (event, ctx) => {
|
|
@@ -26,6 +26,7 @@ export function createLinkDocumentHandler(args: LinkHandlerArgs) {
|
|
|
26
26
|
name: "link",
|
|
27
27
|
schema: documentLinkPayloadSchema,
|
|
28
28
|
access,
|
|
29
|
+
agent: { risk: "mid" },
|
|
29
30
|
description:
|
|
30
31
|
"Makes a document visible on another record by linking it to that record; the caller must be able to read the document and edit the record.",
|
|
31
32
|
handler: async (event, ctx): Promise<WriteResult<unknown>> => {
|
|
@@ -63,6 +64,7 @@ export function createUnlinkDocumentHandler(args: LinkHandlerArgs) {
|
|
|
63
64
|
name: "unlink",
|
|
64
65
|
schema: documentLinkPayloadSchema,
|
|
65
66
|
access,
|
|
67
|
+
agent: { risk: "mid" },
|
|
66
68
|
description:
|
|
67
69
|
"Removes a document from a record it was linked to, reporting success when no link existed; the document and its file stay.",
|
|
68
70
|
handler: async (event, ctx): Promise<WriteResult<unknown>> => {
|
|
@@ -20,6 +20,20 @@ export const DOCUMENTS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
20
20
|
"documents:list.expiry.expiring": { en: "Expiring soon", de: "Läuft bald ab" },
|
|
21
21
|
"documents:list.expiry.valid": { en: "Valid", de: "Gültig" },
|
|
22
22
|
"documents:list.expiry.none": { en: "No expiry date", de: "Ohne Ablaufdatum" },
|
|
23
|
+
"documents:write:link:title": {
|
|
24
|
+
en: "Link document to record",
|
|
25
|
+
de: "Dokument mit Datensatz verknüpfen",
|
|
26
|
+
},
|
|
27
|
+
"documents:write:link:field:documentId": { en: "Document", de: "Dokument" },
|
|
28
|
+
"documents:write:link:field:entityType": { en: "Record type", de: "Datensatztyp" },
|
|
29
|
+
"documents:write:link:field:entityId": { en: "Record", de: "Datensatz" },
|
|
30
|
+
"documents:write:unlink:title": {
|
|
31
|
+
en: "Remove document from record",
|
|
32
|
+
de: "Dokument von Datensatz lösen",
|
|
33
|
+
},
|
|
34
|
+
"documents:write:unlink:field:documentId": { en: "Document", de: "Dokument" },
|
|
35
|
+
"documents:write:unlink:field:entityType": { en: "Record type", de: "Datensatztyp" },
|
|
36
|
+
"documents:write:unlink:field:entityId": { en: "Record", de: "Datensatz" },
|
|
23
37
|
"documents.errors.unknown-category": {
|
|
24
38
|
en: "Unknown document category",
|
|
25
39
|
de: "Unbekannte Dokumentenkategorie",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EditRelatedListSection } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type { DashboardStatPanel } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { DocumentsQueries } from "./constants.ts";
|
|
3
|
+
import { DOCUMENT_ENTITY, DOCUMENTS_FEATURE_NAME, DocumentsQueries } from "./constants.ts";
|
|
4
4
|
import { forTypeQueryQn } from "./queries/for-record.query.ts";
|
|
5
5
|
|
|
6
6
|
// "Documents" tab for the edit screen of a link target. The tab id is the same on every screen,
|
|
@@ -11,7 +11,7 @@ export function documentsTab(entityType: string): EditRelatedListSection {
|
|
|
11
11
|
kind: "relatedList",
|
|
12
12
|
title: "documents:tab.title",
|
|
13
13
|
query: forTypeQueryQn(entityType),
|
|
14
|
-
entity:
|
|
14
|
+
entity: `${DOCUMENTS_FEATURE_NAME}:${DOCUMENT_ENTITY}`,
|
|
15
15
|
columns: [
|
|
16
16
|
{ field: "title", sortable: true },
|
|
17
17
|
{ field: "category", sortable: true },
|
|
@@ -18,8 +18,10 @@ function updateHandlerAccessOf(registry: Registry, entityType: string) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// Linking a document changes what readers of the target see, so it needs what saving the target
|
|
21
|
-
// needs: read visibility,
|
|
22
|
-
//
|
|
21
|
+
// needs: read visibility, a write gate and its row-level write rule. The gate is the entity's
|
|
22
|
+
// update handler; an entity saved only through custom handlers has none, so its declared write
|
|
23
|
+
// roles stand in, and without either the target stays unlinkable. A target that is not visible
|
|
24
|
+
// answers like a missing one.
|
|
23
25
|
export async function canWriteLinkTarget(
|
|
24
26
|
registry: Registry,
|
|
25
27
|
entityType: string,
|
|
@@ -31,7 +33,11 @@ export async function canWriteLinkTarget(
|
|
|
31
33
|
const entity = registry.getEntity(entityType);
|
|
32
34
|
if (entity === undefined) return false;
|
|
33
35
|
const updateAccess = updateHandlerAccessOf(registry, entityType);
|
|
34
|
-
if (updateAccess
|
|
36
|
+
if (updateAccess !== undefined) {
|
|
37
|
+
if (!hasAccess(user, updateAccess)) return false;
|
|
38
|
+
} else if (!hasAccess(user, { roles: Object.keys(entity.access?.write ?? {}) })) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
35
41
|
const row = await createEntityExecutor(entityType, entity).executor.detail(
|
|
36
42
|
{ id: entityId },
|
|
37
43
|
user,
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "The inbound mail supervisor requires a lock and the dedup anchor is unique",
|
|
6
|
+
"detail": "`createInboundMailSupervisor` takes a required `lock` (a DistributedLock) instead of an optional one, so only one worker holds the IMAP watch per account; the single-process path without a lock is gone. The unmanaged table store_mail_seen_messages gets a unique index on (account_id, provider_message_id), which the ingest comment always promised; ingest-message already serialized concurrent ingests of one mail with a per-message advisory lock, so the second one reports duplicate.",
|
|
7
|
+
"migration": "Pass `lock: createDistributedLock(redis, \"kumiko:lock:inbound-mail:watch:\")` to createInboundMailSupervisor. Run `kumiko-schema generate <name>` in each app that mounts inbound-mail-foundation to get the migration for the unique index on store_mail_seen_messages; existing rows must hold no duplicate (account_id, provider_message_id)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.394.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -231,6 +231,7 @@ export const syncCursorEntity = createEntity({
|
|
|
231
231
|
export const seenMessageEntity = createEntity({
|
|
232
232
|
table: "store_mail_seen_messages",
|
|
233
233
|
softDelete: false,
|
|
234
|
+
indexes: [{ unique: true, columns: ["accountId", "providerMessageId"] }],
|
|
234
235
|
fields: {
|
|
235
236
|
accountId: createTextField({
|
|
236
237
|
required: true,
|
|
@@ -10,7 +10,7 @@ export type WatchLease = {
|
|
|
10
10
|
// The cross-worker claim on one account's watch: acquired before plugin.watch(), renewed by a
|
|
11
11
|
// heartbeat for as long as the watcher state exists, and given up locally once it is lost.
|
|
12
12
|
export function createWatchLease(deps: {
|
|
13
|
-
readonly lock: DistributedLock
|
|
13
|
+
readonly lock: DistributedLock;
|
|
14
14
|
readonly ttlSeconds: number;
|
|
15
15
|
readonly renewIntervalMs: number;
|
|
16
16
|
readonly watchers: ReadonlyMap<string, WatcherState>;
|
|
@@ -53,7 +53,7 @@ export function createWatchLease(deps: {
|
|
|
53
53
|
): Promise<void> {
|
|
54
54
|
state.renewTimer = null;
|
|
55
55
|
// skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
|
|
56
|
-
if (!isRunning() || state.generation !== generation || !state.lockToken
|
|
56
|
+
if (!isRunning() || state.generation !== generation || !state.lockToken) return;
|
|
57
57
|
let renewed: boolean;
|
|
58
58
|
try {
|
|
59
59
|
renewed = await lock.renew(account.id, state.lockToken, ttlSeconds);
|
|
@@ -97,7 +97,7 @@ export function createWatchLease(deps: {
|
|
|
97
97
|
account: MailAccountRecord,
|
|
98
98
|
state: WatcherState,
|
|
99
99
|
): Promise<WatchLeaseResult> {
|
|
100
|
-
if (
|
|
100
|
+
if (state.lockToken) return "acquired";
|
|
101
101
|
// A throwing lock (Redis outage) reads as "not claimed": the next tick retries instead of leaving the start flag set.
|
|
102
102
|
const token = await lock.acquire(account.id, { ttlSeconds: ttlSeconds }).catch((err) => {
|
|
103
103
|
log(
|
|
@@ -41,15 +41,15 @@ export type InboundMailSupervisorDeps = {
|
|
|
41
41
|
/** Coordinates the IMAP watch (not the poll) across worker processes:
|
|
42
42
|
* before `plugin.watch()`, ensureWatcher claims a TTL lease for the
|
|
43
43
|
* account via `lock.acquire` and renews it via `lock.renew` while the
|
|
44
|
-
* watcher lives.
|
|
45
|
-
*
|
|
46
|
-
readonly lock
|
|
44
|
+
* watcher lives. Required: without it every worker would hold its own
|
|
45
|
+
* IDLE connection per account. */
|
|
46
|
+
readonly lock: DistributedLock;
|
|
47
47
|
/** Access-token lifecycle for OAuth accounts. Defaults to a manager built
|
|
48
48
|
* from `db` + `providerCtx.secrets`; pass the instance shared with
|
|
49
49
|
* `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
|
|
50
50
|
readonly oauthTokens?: OAuthAccessTokenManager;
|
|
51
51
|
/** TTL (seconds) for the per-account watch lease. Default 90s, renewed
|
|
52
|
-
* every ttl/3.
|
|
52
|
+
* every ttl/3. */
|
|
53
53
|
readonly watchLeaseTtlSeconds?: number;
|
|
54
54
|
readonly log?: (line: string) => void;
|
|
55
55
|
};
|
|
@@ -62,7 +62,7 @@ export type WatcherState = {
|
|
|
62
62
|
* an already stopped watcher. */
|
|
63
63
|
generation: number;
|
|
64
64
|
/** Token from `deps.lock.acquire` while this worker owns the account's
|
|
65
|
-
* watch lease; null when unclaimed (
|
|
65
|
+
* watch lease; null when unclaimed (claim lost). */
|
|
66
66
|
lockToken: string | null;
|
|
67
67
|
renewTimer: ReturnType<typeof setTimeout> | null;
|
|
68
68
|
/** Consecutive renew() throws — tear down once failures span a full TTL. */
|
|
@@ -17,11 +17,9 @@
|
|
|
17
17
|
// db,
|
|
18
18
|
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
19
19
|
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
20
|
-
// //
|
|
21
|
-
// //
|
|
22
|
-
// //
|
|
23
|
-
// // for a single-process deployment — every active account gets
|
|
24
|
-
// // watched locally, as before.
|
|
20
|
+
// // One DistributedLock (same Redis, same key prefix) shared across
|
|
21
|
+
// // every worker process, so only one of them holds the IMAP IDLE
|
|
22
|
+
// // connection per account.
|
|
25
23
|
// lock: createDistributedLock(redis, `${RedisKeys.lock}inbound-mail:watch:`),
|
|
26
24
|
// });
|
|
27
25
|
// await supervisor.start();
|
|
@@ -34,7 +32,7 @@
|
|
|
34
32
|
// **Multi-worker coordination:** the reconciliation poll remains deliberately
|
|
35
33
|
// N-fold: every worker polls every active account, which is idempotent and
|
|
36
34
|
// cheap. Only `plugin.watch()` holds a long-lived connection, and only one
|
|
37
|
-
// worker may hold it per account.
|
|
35
|
+
// worker may hold it per account. `ensureWatcher` claims a
|
|
38
36
|
// TTL lease (`lock.acquire`) before connecting; a worker that fails to claim
|
|
39
37
|
// it leaves the account to its current holder, while the poll still covers
|
|
40
38
|
// it. The holder renews the lease via heartbeat (`lock.renew`, every ttl/3)
|
|
@@ -435,7 +433,7 @@ export function createInboundMailSupervisor(
|
|
|
435
433
|
log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
|
|
436
434
|
}
|
|
437
435
|
}
|
|
438
|
-
if (
|
|
436
|
+
if (lockToken) {
|
|
439
437
|
try {
|
|
440
438
|
await deps.lock.release(accountId, lockToken);
|
|
441
439
|
} catch (err) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The public render caches a sanitized document body per page version",
|
|
6
|
+
"detail": "The render cache holds up to 500 entries keyed on tenant, slug, language, version and format. Bodies larger than 256 KB are sanitized on every render and not cached."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.395.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -263,7 +263,15 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
263
263
|
|
|
264
264
|
const html = wrapLayout({
|
|
265
265
|
title: data.title,
|
|
266
|
-
bodyHtml: renderPageBody({
|
|
266
|
+
bodyHtml: renderPageBody({
|
|
267
|
+
tenantId,
|
|
268
|
+
slug,
|
|
269
|
+
lang,
|
|
270
|
+
version: data.version,
|
|
271
|
+
updatedAt: data.updatedAt,
|
|
272
|
+
body: data.body,
|
|
273
|
+
bodyFormat: data.bodyFormat,
|
|
274
|
+
}),
|
|
267
275
|
lang: data.lang,
|
|
268
276
|
slug,
|
|
269
277
|
description: data.description,
|
|
@@ -7,22 +7,76 @@ type WarnLogger = Pick<Logger, "warn">;
|
|
|
7
7
|
|
|
8
8
|
const defaultLog = createFallbackLogger("managed-pages");
|
|
9
9
|
|
|
10
|
+
const MAX_CACHED_BODIES = 500;
|
|
11
|
+
// Bounds the cache memory: 500 entries of arbitrary size could otherwise pin
|
|
12
|
+
// hundreds of MB. A bigger body is still served, just re-rendered per request.
|
|
13
|
+
const MAX_CACHED_BODY_LENGTH = 256 * 1024;
|
|
14
|
+
|
|
10
15
|
export type RenderablePageBody = {
|
|
16
|
+
readonly tenantId: string;
|
|
11
17
|
readonly slug: string;
|
|
18
|
+
readonly lang: string;
|
|
19
|
+
readonly version: number;
|
|
20
|
+
readonly updatedAt: string;
|
|
12
21
|
readonly body: string;
|
|
13
22
|
readonly bodyFormat: BodyFormat;
|
|
14
23
|
};
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
export type PageBodyRendererDeps = {
|
|
26
|
+
readonly sanitizeDocument?: typeof sanitizeDocumentHtml;
|
|
27
|
+
readonly renderMarkdown?: (markdown: string) => string;
|
|
28
|
+
readonly maxEntries?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Every write bumps the version, so a (page, version) key never serves stale
|
|
32
|
+
// HTML. A Map keeps insertion order: delete+reinsert on a hit makes the first
|
|
33
|
+
// key the least recently used one.
|
|
34
|
+
function cacheKeyOf(page: RenderablePageBody): string {
|
|
35
|
+
return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join(
|
|
36
|
+
"\u0000",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Writes store the rebuilt, sanitized HTML, but rows can predate that validation
|
|
41
|
+
// or arrive through other writers, so a render still sanitizes — once per page
|
|
42
|
+
// version instead of once per request. A rejected document renders as an empty
|
|
43
|
+
// body (never the raw HTML), is logged on the miss, and the empty result is cached.
|
|
44
|
+
export function createPageBodyRenderer(
|
|
45
|
+
deps: PageBodyRendererDeps = {},
|
|
46
|
+
): (page: RenderablePageBody, log?: WarnLogger) => string {
|
|
47
|
+
const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
|
|
48
|
+
const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
|
|
49
|
+
const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
|
|
50
|
+
const cache = new Map<string, string>();
|
|
51
|
+
|
|
52
|
+
function renderUncached(page: RenderablePageBody, log: WarnLogger): string {
|
|
53
|
+
if (page.bodyFormat === "markdown") return renderMarkdown(page.body);
|
|
54
|
+
const result = sanitizeDocument(page.body);
|
|
55
|
+
if (result.ok) return result.html;
|
|
56
|
+
log.warn("managed page document body rejected at render, serving an empty body", {
|
|
57
|
+
slug: page.slug,
|
|
58
|
+
violations: result.violations.map((violation) => violation.code),
|
|
59
|
+
});
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (page, log = defaultLog) => {
|
|
64
|
+
const key = cacheKeyOf(page);
|
|
65
|
+
const cached = cache.get(key);
|
|
66
|
+
if (cached !== undefined) {
|
|
67
|
+
cache.delete(key);
|
|
68
|
+
cache.set(key, cached);
|
|
69
|
+
return cached;
|
|
70
|
+
}
|
|
71
|
+
const html = renderUncached(page, log);
|
|
72
|
+
if (html.length > MAX_CACHED_BODY_LENGTH) return html;
|
|
73
|
+
cache.set(key, html);
|
|
74
|
+
if (cache.size > maxEntries) {
|
|
75
|
+
const oldest = cache.keys().next();
|
|
76
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
77
|
+
}
|
|
78
|
+
return html;
|
|
79
|
+
};
|
|
28
80
|
}
|
|
81
|
+
|
|
82
|
+
export const renderPageBody = createPageBodyRenderer();
|
|
@@ -65,7 +65,7 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
65
65
|
reason: "is_business_data",
|
|
66
66
|
}),
|
|
67
67
|
logoFile: createImageField({
|
|
68
|
-
accept: ["
|
|
68
|
+
accept: ["png", "jpg", "jpeg", "webp"],
|
|
69
69
|
personal: false,
|
|
70
70
|
reason: "is_business_data",
|
|
71
71
|
}),
|
|
@@ -57,11 +57,10 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
57
57
|
"managed-pages:body.lossy.warning": {
|
|
58
58
|
en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
|
|
59
59
|
},
|
|
60
|
-
"managed-pages:body.lossy.image": { en: "images" },
|
|
60
|
+
"managed-pages:body.lossy.image": { en: "external images" },
|
|
61
61
|
"managed-pages:body.lossy.blockquote": { en: "block quotes" },
|
|
62
62
|
"managed-pages:body.lossy.code": { en: "code" },
|
|
63
63
|
"managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
|
|
64
|
-
"managed-pages:body.lossy.relativeLink": { en: "relative links" },
|
|
65
64
|
|
|
66
65
|
// Row-Actions + Confirm (pageListScreen)
|
|
67
66
|
"managed-pages:actions.edit": { en: "Edit" },
|
|
@@ -1,18 +1,9 @@
|
|
|
1
|
-
import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
|
|
2
1
|
import { Lexer, type Token } from "marked";
|
|
3
2
|
|
|
4
|
-
export const LOSSY_MARKDOWN_FEATURES = [
|
|
5
|
-
"image",
|
|
6
|
-
"blockquote",
|
|
7
|
-
"code",
|
|
8
|
-
"deepHeading",
|
|
9
|
-
"relativeLink",
|
|
10
|
-
] as const;
|
|
3
|
+
export const LOSSY_MARKDOWN_FEATURES = ["image", "blockquote", "code", "deepHeading"] as const;
|
|
11
4
|
export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
|
|
12
5
|
|
|
13
|
-
// The document editor has no image, quote or code nodes
|
|
14
|
-
// levels, and links must pass the document link policy (absolute http(s),
|
|
15
|
-
// mailto, tel, placeholders).
|
|
6
|
+
// The document editor has no image, quote or code nodes and only three heading levels.
|
|
16
7
|
const MAX_DOCUMENT_HEADING_DEPTH = 3;
|
|
17
8
|
|
|
18
9
|
function tokenList(value: unknown): readonly Token[] {
|
|
@@ -31,12 +22,6 @@ function isDeepHeading(token: Token): boolean {
|
|
|
31
22
|
);
|
|
32
23
|
}
|
|
33
24
|
|
|
34
|
-
function isRelativeLink(token: Token): boolean {
|
|
35
|
-
return (
|
|
36
|
-
"href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href)
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
25
|
function featuresOf(token: Token): readonly LossyMarkdownFeature[] {
|
|
41
26
|
switch (token.type) {
|
|
42
27
|
case "image":
|
|
@@ -48,8 +33,6 @@ function featuresOf(token: Token): readonly LossyMarkdownFeature[] {
|
|
|
48
33
|
return ["code"];
|
|
49
34
|
case "heading":
|
|
50
35
|
return isDeepHeading(token) ? ["deepHeading"] : [];
|
|
51
|
-
case "link":
|
|
52
|
-
return isRelativeLink(token) ? ["relativeLink"] : [];
|
|
53
36
|
default:
|
|
54
37
|
return [];
|
|
55
38
|
}
|
|
@@ -3,8 +3,8 @@ import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
|
3
3
|
import { Marked } from "marked";
|
|
4
4
|
|
|
5
5
|
// The server sanitizer rejects what it cannot keep, so the conversion drops it up front and the
|
|
6
|
-
// lossy banner stays true: a link outside the document link policy keeps its text, an
|
|
7
|
-
// (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
6
|
+
// lossy banner stays true: a link outside the document link policy keeps its text, an external
|
|
7
|
+
// image (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
8
8
|
const converter = new Marked({ gfm: false, breaks: false });
|
|
9
9
|
converter.use({
|
|
10
10
|
renderer: {
|
|
@@ -14,7 +14,6 @@ const LOSSY_LABEL_KEYS: Readonly<Record<LossyMarkdownFeature, string>> = {
|
|
|
14
14
|
blockquote: "managed-pages:body.lossy.blockquote",
|
|
15
15
|
code: "managed-pages:body.lossy.code",
|
|
16
16
|
deepHeading: "managed-pages:body.lossy.deepHeading",
|
|
17
|
-
relativeLink: "managed-pages:body.lossy.relativeLink",
|
|
18
17
|
};
|
|
19
18
|
|
|
20
19
|
const BODY_EDITOR_ID = "managed-pages-page-body";
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "System-check history keeps runs of the same second in order",
|
|
6
|
+
"detail": "`probe-run:list` returned `requestedAt` as `Instant.toString()`, which drops trailing zeros in the fraction, and `pageRows` sorts that field as a string. Two runs in the same second could then come out reversed (`…:20Z` sorted after `…:20.001Z`), so the history showed the older run on top and a click opened it. `requestedAt` now always carries six fractional digits, so string order matches time order."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.392.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -29,7 +29,8 @@ export const runListQuery = defineQueryHandler({
|
|
|
29
29
|
const { counts, outcome } = viewRun(run, query.user);
|
|
30
30
|
return {
|
|
31
31
|
id: run.id,
|
|
32
|
-
|
|
32
|
+
// Fixed width: pageRows sorts this as a string, and a trimmed fraction ("…20Z" vs "…20.001Z") sorts wrong.
|
|
33
|
+
requestedAt: run.requestedAt.toString({ fractionalSecondDigits: 6 }),
|
|
33
34
|
triggeredByUserId: run.triggeredByUserId,
|
|
34
35
|
triggeredByName: starterNames.get(run.triggeredByUserId) ?? run.triggeredByUserId,
|
|
35
36
|
status: run.status,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
4
|
import { PROBE_RUN_STATUS, probeRunsTable } from "./schema/probe-run.ts";
|
|
4
5
|
import {
|
|
5
6
|
countResults,
|
|
@@ -10,14 +11,12 @@ import {
|
|
|
10
11
|
visibleResults,
|
|
11
12
|
} from "./visible-results.ts";
|
|
12
13
|
|
|
13
|
-
type InstantLike = { readonly toString: () => string };
|
|
14
|
-
|
|
15
14
|
export type RunRow = {
|
|
16
15
|
readonly id: string;
|
|
17
16
|
readonly status: string;
|
|
18
|
-
readonly requestedAt:
|
|
19
|
-
readonly startedAt:
|
|
20
|
-
readonly finishedAt:
|
|
17
|
+
readonly requestedAt: Temporal.Instant;
|
|
18
|
+
readonly startedAt: Temporal.Instant | null;
|
|
19
|
+
readonly finishedAt: Temporal.Instant | null;
|
|
21
20
|
readonly triggeredByUserId: string;
|
|
22
21
|
readonly durationMs: number | null;
|
|
23
22
|
readonly results: unknown;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Document anchors accept relative links",
|
|
6
|
+
"detail": "An anchor href in a document body may be relative (/path, ./, ../, #, ?); never // or a backslash. Other URL attributes (image src and similar) and mail links still accept only explicit schemes or placeholders. The sanitizer, the editor schema, compileDocumentBody and the confirmation mail use the same split."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.402.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Collection nav takes entryLabel for the create title",
|
|
12
|
+
"detail": "A collection's nav takes an optional entryLabel (singular i18n key, new in the nav type and in the required surface keys of all locale packages). The create screen title reads \"New {label}\", or \"New entry\" without it."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.395.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -7,7 +7,7 @@ import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.
|
|
|
7
7
|
import {
|
|
8
8
|
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
9
9
|
DOCUMENT_TEXT_ALIGNMENTS,
|
|
10
|
-
|
|
10
|
+
isAllowedDocumentUrlHref,
|
|
11
11
|
isDocumentPlaceholderHref,
|
|
12
12
|
isDocumentReferenceName,
|
|
13
13
|
isDocumentResourceKey,
|
|
@@ -170,7 +170,7 @@ function rewriteLink(element: ScanElement, state: CompileState): void {
|
|
|
170
170
|
} else {
|
|
171
171
|
element.setAttribute("href", "#");
|
|
172
172
|
}
|
|
173
|
-
} else if (!
|
|
173
|
+
} else if (!isAllowedDocumentUrlHref(href)) {
|
|
174
174
|
element.setAttribute("href", "#");
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
12
12
|
documentExtensions,
|
|
13
13
|
isAllowedDocumentLinkHref,
|
|
14
|
+
isAllowedDocumentUrlHref,
|
|
14
15
|
isDocumentPlaceholderHref,
|
|
15
16
|
isDocumentReferenceName,
|
|
16
17
|
isDocumentResourceKey,
|
|
@@ -101,7 +102,7 @@ function scanAnchor(element: ScanElement, out: DocumentViolation[]): void {
|
|
|
101
102
|
violation(
|
|
102
103
|
"disallowed_link_href",
|
|
103
104
|
href,
|
|
104
|
-
`Link target "${href}" is not allowed; use https, http, mailto, tel or a {{placeholder}}`,
|
|
105
|
+
`Link target "${href}" is not allowed; use https, http, mailto, tel, a relative path or a {{placeholder}}`,
|
|
105
106
|
),
|
|
106
107
|
);
|
|
107
108
|
}
|
|
@@ -197,7 +198,7 @@ function scanUrlAttributes(tag: string, element: ScanElement, out: DocumentViola
|
|
|
197
198
|
`Images must reference an uploaded resource via data-resource; image source "${value}" is not allowed`,
|
|
198
199
|
),
|
|
199
200
|
);
|
|
200
|
-
} else if (tag === "base" || !
|
|
201
|
+
} else if (tag === "base" || !isAllowedDocumentUrlHref(value)) {
|
|
201
202
|
out.push(
|
|
202
203
|
violation(
|
|
203
204
|
"disallowed_link_href",
|