@cosmicdrift/kumiko-bundled-features 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/dist/agent-tools/agent-manifest.js +123 -46
- package/dist/agent-tools/tool-catalog.d.ts +6 -0
- package/dist/agent-tools/tool-catalog.js +10 -11
- package/dist/agent-tools/types.d.ts +11 -10
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +2 -2
- package/dist/documents/entity.d.ts +2 -0
- package/dist/documents/entity.js +4 -1
- package/dist/documents/feature.d.ts +14 -1
- package/dist/documents/feature.js +15 -4
- package/dist/documents/handlers/import.write.js +1 -1
- package/dist/documents/handlers/link.write.js +2 -0
- package/dist/documents/i18n.js +14 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/tab.js +2 -2
- package/dist/documents/target-write-check.js +10 -3
- package/dist/inbound-mail-foundation/entities.js +1 -0
- package/dist/inbound-mail-foundation/watch-lease.d.ts +1 -1
- package/dist/inbound-mail-foundation/watch-lease.js +2 -2
- package/dist/inbound-mail-foundation/watch-supervisor-types.d.ts +5 -5
- package/dist/inbound-mail-foundation/watch-supervisor.js +5 -7
- package/dist/managed-pages/feature.js +9 -1
- package/dist/managed-pages/render-page-body.d.ts +12 -1
- package/dist/managed-pages/render-page-body.js +51 -14
- package/dist/managed-pages/screens/branding-screen.js +1 -1
- package/dist/managed-pages/web/i18n.js +1 -2
- package/dist/managed-pages/web/lossy-markdown.d.ts +1 -1
- package/dist/managed-pages/web/lossy-markdown.js +2 -16
- package/dist/managed-pages/web/markdown-to-document.js +2 -2
- package/dist/managed-pages/web/page-body-section.js +0 -1
- package/dist/readiness/handlers/run-list.query.js +2 -1
- package/dist/readiness/run-view.d.ts +4 -7
- package/dist/template-resolver/compile-document-body.js +2 -2
- package/dist/template-resolver/document-sanitizer.js +3 -3
- package/dist/template-resolver/index.d.ts +1 -1
- package/dist/template-resolver/index.js +1 -1
- package/dist/template-resolver/web/document-schema.d.ts +1 -0
- package/dist/template-resolver/web/document-schema.js +16 -1
- package/dist/template-resolver/web/i18n.js +2 -2
- package/dist/template-resolver/web/use-collection-entry-create.js +4 -3
- package/dist/user-data-rights/boot-checks.js +38 -31
- package/dist/user-data-rights/boot-rules.d.ts +17 -0
- package/dist/user-data-rights/boot-rules.js +17 -0
- package/dist/user-data-rights/web/i18n.js +4 -0
- package/dist/user-data-rights/web/request-deletion-screen.js +3 -3
- package/package.json +11 -11
- package/src/agent-tools/changes.json +19 -0
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/readiness/changes.json +6 -0
- package/src/template-resolver/changes.json +12 -0
- package/src/user-data-rights/changes.json +12 -0
package/dist/documents/tab.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DocumentsQueries } from "./constants.js";
|
|
1
|
+
import { DOCUMENT_ENTITY, DOCUMENTS_FEATURE_NAME, DocumentsQueries } from "./constants.js";
|
|
2
2
|
import { forTypeQueryQn } from "./queries/for-record.query.js";
|
|
3
3
|
// "Documents" tab for the edit screen of a link target. The tab id is the same on every screen,
|
|
4
4
|
// so deep links like ?tab=documents work across hosts.
|
|
@@ -8,7 +8,7 @@ export function documentsTab(entityType) {
|
|
|
8
8
|
kind: "relatedList",
|
|
9
9
|
title: "documents:tab.title",
|
|
10
10
|
query: forTypeQueryQn(entityType),
|
|
11
|
-
entity:
|
|
11
|
+
entity: `${DOCUMENTS_FEATURE_NAME}:${DOCUMENT_ENTITY}`,
|
|
12
12
|
columns: [
|
|
13
13
|
{ field: "title", sortable: true },
|
|
14
14
|
{ field: "category", sortable: true },
|
|
@@ -9,8 +9,10 @@ function updateHandlerAccessOf(registry, entityType) {
|
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
// Linking a document changes what readers of the target see, so it needs what saving the target
|
|
12
|
-
// needs: read visibility,
|
|
13
|
-
//
|
|
12
|
+
// needs: read visibility, a write gate and its row-level write rule. The gate is the entity's
|
|
13
|
+
// update handler; an entity saved only through custom handlers has none, so its declared write
|
|
14
|
+
// roles stand in, and without either the target stays unlinkable. A target that is not visible
|
|
15
|
+
// answers like a missing one.
|
|
14
16
|
export async function canWriteLinkTarget(registry, entityType, entityId, user, db) {
|
|
15
17
|
if (!(await parentRowIsVisible(registry, entityType, entityId, user, db)))
|
|
16
18
|
return false;
|
|
@@ -18,8 +20,13 @@ export async function canWriteLinkTarget(registry, entityType, entityId, user, d
|
|
|
18
20
|
if (entity === undefined)
|
|
19
21
|
return false;
|
|
20
22
|
const updateAccess = updateHandlerAccessOf(registry, entityType);
|
|
21
|
-
if (updateAccess
|
|
23
|
+
if (updateAccess !== undefined) {
|
|
24
|
+
if (!hasAccess(user, updateAccess))
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
else if (!hasAccess(user, { roles: Object.keys(entity.access?.write ?? {}) })) {
|
|
22
28
|
return false;
|
|
29
|
+
}
|
|
23
30
|
const row = await createEntityExecutor(entityType, entity).executor.detail({ id: entityId }, user, db);
|
|
24
31
|
return row !== null && userCanWriteFieldRow(user, entity.access?.write, row, row);
|
|
25
32
|
}
|
|
@@ -214,6 +214,7 @@ export const syncCursorEntity = createEntity({
|
|
|
214
214
|
export const seenMessageEntity = createEntity({
|
|
215
215
|
table: "store_mail_seen_messages",
|
|
216
216
|
softDelete: false,
|
|
217
|
+
indexes: [{ unique: true, columns: ["accountId", "providerMessageId"] }],
|
|
217
218
|
fields: {
|
|
218
219
|
accountId: createTextField({
|
|
219
220
|
required: true,
|
|
@@ -5,7 +5,7 @@ export type WatchLease = {
|
|
|
5
5
|
readonly acquire: (account: MailAccountRecord, state: WatcherState) => Promise<WatchLeaseResult>;
|
|
6
6
|
};
|
|
7
7
|
export declare function createWatchLease(deps: {
|
|
8
|
-
readonly lock: DistributedLock
|
|
8
|
+
readonly lock: DistributedLock;
|
|
9
9
|
readonly ttlSeconds: number;
|
|
10
10
|
readonly renewIntervalMs: number;
|
|
11
11
|
readonly watchers: ReadonlyMap<string, WatcherState>;
|
|
@@ -20,7 +20,7 @@ export function createWatchLease(deps) {
|
|
|
20
20
|
async function renewLeaseOnce(account, state, generation) {
|
|
21
21
|
state.renewTimer = null;
|
|
22
22
|
// skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
|
|
23
|
-
if (!isRunning() || state.generation !== generation || !state.lockToken
|
|
23
|
+
if (!isRunning() || state.generation !== generation || !state.lockToken)
|
|
24
24
|
return;
|
|
25
25
|
let renewed;
|
|
26
26
|
try {
|
|
@@ -55,7 +55,7 @@ export function createWatchLease(deps) {
|
|
|
55
55
|
}
|
|
56
56
|
// kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional
|
|
57
57
|
async function acquire(account, state) {
|
|
58
|
-
if (
|
|
58
|
+
if (state.lockToken)
|
|
59
59
|
return "acquired";
|
|
60
60
|
// A throwing lock (Redis outage) reads as "not claimed": the next tick retries instead of leaving the start flag set.
|
|
61
61
|
const token = await lock.acquire(account.id, { ttlSeconds: ttlSeconds }).catch((err) => {
|
|
@@ -40,15 +40,15 @@ export type InboundMailSupervisorDeps = {
|
|
|
40
40
|
/** Coordinates the IMAP watch (not the poll) across worker processes:
|
|
41
41
|
* before `plugin.watch()`, ensureWatcher claims a TTL lease for the
|
|
42
42
|
* account via `lock.acquire` and renews it via `lock.renew` while the
|
|
43
|
-
* watcher lives.
|
|
44
|
-
*
|
|
45
|
-
readonly lock
|
|
43
|
+
* watcher lives. Required: without it every worker would hold its own
|
|
44
|
+
* IDLE connection per account. */
|
|
45
|
+
readonly lock: DistributedLock;
|
|
46
46
|
/** Access-token lifecycle for OAuth accounts. Defaults to a manager built
|
|
47
47
|
* from `db` + `providerCtx.secrets`; pass the instance shared with
|
|
48
48
|
* `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
|
|
49
49
|
readonly oauthTokens?: OAuthAccessTokenManager;
|
|
50
50
|
/** TTL (seconds) for the per-account watch lease. Default 90s, renewed
|
|
51
|
-
* every ttl/3.
|
|
51
|
+
* every ttl/3. */
|
|
52
52
|
readonly watchLeaseTtlSeconds?: number;
|
|
53
53
|
readonly log?: (line: string) => void;
|
|
54
54
|
};
|
|
@@ -60,7 +60,7 @@ export type WatcherState = {
|
|
|
60
60
|
* an already stopped watcher. */
|
|
61
61
|
generation: number;
|
|
62
62
|
/** Token from `deps.lock.acquire` while this worker owns the account's
|
|
63
|
-
* watch lease; null when unclaimed (
|
|
63
|
+
* watch lease; null when unclaimed (claim lost). */
|
|
64
64
|
lockToken: string | null;
|
|
65
65
|
renewTimer: ReturnType<typeof setTimeout> | null;
|
|
66
66
|
/** 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)
|
|
@@ -375,7 +373,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
375
373
|
log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
|
|
376
374
|
}
|
|
377
375
|
}
|
|
378
|
-
if (
|
|
376
|
+
if (lockToken) {
|
|
379
377
|
try {
|
|
380
378
|
await deps.lock.release(accountId, lockToken);
|
|
381
379
|
}
|
|
@@ -184,7 +184,15 @@ export function createManagedPagesFeature(opts) {
|
|
|
184
184
|
}
|
|
185
185
|
const html = wrapLayout({
|
|
186
186
|
title: data.title,
|
|
187
|
-
bodyHtml: renderPageBody({
|
|
187
|
+
bodyHtml: renderPageBody({
|
|
188
|
+
tenantId,
|
|
189
|
+
slug,
|
|
190
|
+
lang,
|
|
191
|
+
version: data.version,
|
|
192
|
+
updatedAt: data.updatedAt,
|
|
193
|
+
body: data.body,
|
|
194
|
+
bodyFormat: data.bodyFormat,
|
|
195
|
+
}),
|
|
188
196
|
lang: data.lang,
|
|
189
197
|
slug,
|
|
190
198
|
description: data.description,
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
|
|
2
3
|
import type { BodyFormat } from "./constants.ts";
|
|
3
4
|
type WarnLogger = Pick<Logger, "warn">;
|
|
4
5
|
export type RenderablePageBody = {
|
|
6
|
+
readonly tenantId: string;
|
|
5
7
|
readonly slug: string;
|
|
8
|
+
readonly lang: string;
|
|
9
|
+
readonly version: number;
|
|
10
|
+
readonly updatedAt: string;
|
|
6
11
|
readonly body: string;
|
|
7
12
|
readonly bodyFormat: BodyFormat;
|
|
8
13
|
};
|
|
9
|
-
export
|
|
14
|
+
export type PageBodyRendererDeps = {
|
|
15
|
+
readonly sanitizeDocument?: typeof sanitizeDocumentHtml;
|
|
16
|
+
readonly renderMarkdown?: (markdown: string) => string;
|
|
17
|
+
readonly maxEntries?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function createPageBodyRenderer(deps?: PageBodyRendererDeps): (page: RenderablePageBody, log?: WarnLogger) => string;
|
|
20
|
+
export declare const renderPageBody: (page: RenderablePageBody, log?: WarnLogger) => string;
|
|
10
21
|
export {};
|
|
@@ -2,18 +2,55 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
|
2
2
|
import { renderSafeMarkdown } from "../page-render/index.js";
|
|
3
3
|
import { sanitizeDocumentHtml } from "../template-resolver/index.js";
|
|
4
4
|
const defaultLog = createFallbackLogger("managed-pages");
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
log.warn("managed page document body rejected at render, serving an empty body", {
|
|
15
|
-
slug: page.slug,
|
|
16
|
-
violations: result.violations.map((violation) => violation.code),
|
|
17
|
-
});
|
|
18
|
-
return "";
|
|
5
|
+
const MAX_CACHED_BODIES = 500;
|
|
6
|
+
// Bounds the cache memory: 500 entries of arbitrary size could otherwise pin
|
|
7
|
+
// hundreds of MB. A bigger body is still served, just re-rendered per request.
|
|
8
|
+
const MAX_CACHED_BODY_LENGTH = 256 * 1024;
|
|
9
|
+
// Every write bumps the version, so a (page, version) key never serves stale
|
|
10
|
+
// HTML. A Map keeps insertion order: delete+reinsert on a hit makes the first
|
|
11
|
+
// key the least recently used one.
|
|
12
|
+
function cacheKeyOf(page) {
|
|
13
|
+
return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join("\u0000");
|
|
19
14
|
}
|
|
15
|
+
// Writes store the rebuilt, sanitized HTML, but rows can predate that validation
|
|
16
|
+
// or arrive through other writers, so a render still sanitizes — once per page
|
|
17
|
+
// version instead of once per request. A rejected document renders as an empty
|
|
18
|
+
// body (never the raw HTML), is logged on the miss, and the empty result is cached.
|
|
19
|
+
export function createPageBodyRenderer(deps = {}) {
|
|
20
|
+
const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
|
|
21
|
+
const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
|
|
22
|
+
const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
function renderUncached(page, log) {
|
|
25
|
+
if (page.bodyFormat === "markdown")
|
|
26
|
+
return renderMarkdown(page.body);
|
|
27
|
+
const result = sanitizeDocument(page.body);
|
|
28
|
+
if (result.ok)
|
|
29
|
+
return result.html;
|
|
30
|
+
log.warn("managed page document body rejected at render, serving an empty body", {
|
|
31
|
+
slug: page.slug,
|
|
32
|
+
violations: result.violations.map((violation) => violation.code),
|
|
33
|
+
});
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
return (page, log = defaultLog) => {
|
|
37
|
+
const key = cacheKeyOf(page);
|
|
38
|
+
const cached = cache.get(key);
|
|
39
|
+
if (cached !== undefined) {
|
|
40
|
+
cache.delete(key);
|
|
41
|
+
cache.set(key, cached);
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
const html = renderUncached(page, log);
|
|
45
|
+
if (html.length > MAX_CACHED_BODY_LENGTH)
|
|
46
|
+
return html;
|
|
47
|
+
cache.set(key, html);
|
|
48
|
+
if (cache.size > maxEntries) {
|
|
49
|
+
const oldest = cache.keys().next();
|
|
50
|
+
if (!oldest.done)
|
|
51
|
+
cache.delete(oldest.value);
|
|
52
|
+
}
|
|
53
|
+
return html;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export const renderPageBody = createPageBodyRenderer();
|
|
@@ -56,7 +56,7 @@ export function createBrandingSettingsScreen(opts) {
|
|
|
56
56
|
reason: "is_business_data",
|
|
57
57
|
}),
|
|
58
58
|
logoFile: createImageField({
|
|
59
|
-
accept: ["
|
|
59
|
+
accept: ["png", "jpg", "jpeg", "webp"],
|
|
60
60
|
personal: false,
|
|
61
61
|
reason: "is_business_data",
|
|
62
62
|
}),
|
|
@@ -50,11 +50,10 @@ export const MANAGED_PAGES_I18N = {
|
|
|
50
50
|
"managed-pages:body.lossy.warning": {
|
|
51
51
|
en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
|
|
52
52
|
},
|
|
53
|
-
"managed-pages:body.lossy.image": { en: "images" },
|
|
53
|
+
"managed-pages:body.lossy.image": { en: "external images" },
|
|
54
54
|
"managed-pages:body.lossy.blockquote": { en: "block quotes" },
|
|
55
55
|
"managed-pages:body.lossy.code": { en: "code" },
|
|
56
56
|
"managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
|
|
57
|
-
"managed-pages:body.lossy.relativeLink": { en: "relative links" },
|
|
58
57
|
// Row-Actions + Confirm (pageListScreen)
|
|
59
58
|
"managed-pages:actions.edit": { en: "Edit" },
|
|
60
59
|
"managed-pages:actions.delete": { en: "Delete" },
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const LOSSY_MARKDOWN_FEATURES: readonly ["image", "blockquote", "code", "deepHeading"
|
|
1
|
+
export declare const LOSSY_MARKDOWN_FEATURES: readonly ["image", "blockquote", "code", "deepHeading"];
|
|
2
2
|
export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
|
|
3
3
|
export declare function detectLossyMarkdown(markdown: string): readonly LossyMarkdownFeature[];
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
|
|
2
1
|
import { Lexer } from "marked";
|
|
3
|
-
export const LOSSY_MARKDOWN_FEATURES = [
|
|
4
|
-
|
|
5
|
-
"blockquote",
|
|
6
|
-
"code",
|
|
7
|
-
"deepHeading",
|
|
8
|
-
"relativeLink",
|
|
9
|
-
];
|
|
10
|
-
// The document editor has no image, quote or code nodes, only three heading
|
|
11
|
-
// levels, and links must pass the document link policy (absolute http(s),
|
|
12
|
-
// mailto, tel, placeholders).
|
|
2
|
+
export const LOSSY_MARKDOWN_FEATURES = ["image", "blockquote", "code", "deepHeading"];
|
|
3
|
+
// The document editor has no image, quote or code nodes and only three heading levels.
|
|
13
4
|
const MAX_DOCUMENT_HEADING_DEPTH = 3;
|
|
14
5
|
function tokenList(value) {
|
|
15
6
|
return Array.isArray(value) ? value : [];
|
|
@@ -23,9 +14,6 @@ function childTokens(token) {
|
|
|
23
14
|
function isDeepHeading(token) {
|
|
24
15
|
return ("depth" in token && typeof token.depth === "number" && token.depth > MAX_DOCUMENT_HEADING_DEPTH);
|
|
25
16
|
}
|
|
26
|
-
function isRelativeLink(token) {
|
|
27
|
-
return ("href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href));
|
|
28
|
-
}
|
|
29
17
|
function featuresOf(token) {
|
|
30
18
|
switch (token.type) {
|
|
31
19
|
case "image":
|
|
@@ -37,8 +25,6 @@ function featuresOf(token) {
|
|
|
37
25
|
return ["code"];
|
|
38
26
|
case "heading":
|
|
39
27
|
return isDeepHeading(token) ? ["deepHeading"] : [];
|
|
40
|
-
case "link":
|
|
41
|
-
return isRelativeLink(token) ? ["relativeLink"] : [];
|
|
42
28
|
default:
|
|
43
29
|
return [];
|
|
44
30
|
}
|
|
@@ -2,8 +2,8 @@ import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/
|
|
|
2
2
|
import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
3
|
import { Marked } from "marked";
|
|
4
4
|
// The server sanitizer rejects what it cannot keep, so the conversion drops it up front and the
|
|
5
|
-
// lossy banner stays true: a link outside the document link policy keeps its text, an
|
|
6
|
-
// (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
5
|
+
// lossy banner stays true: a link outside the document link policy keeps its text, an external
|
|
6
|
+
// image (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
7
7
|
const converter = new Marked({ gfm: false, breaks: false });
|
|
8
8
|
converter.use({
|
|
9
9
|
renderer: {
|
|
@@ -10,7 +10,6 @@ const LOSSY_LABEL_KEYS = {
|
|
|
10
10
|
blockquote: "managed-pages:body.lossy.blockquote",
|
|
11
11
|
code: "managed-pages:body.lossy.code",
|
|
12
12
|
deepHeading: "managed-pages:body.lossy.deepHeading",
|
|
13
|
-
relativeLink: "managed-pages:body.lossy.relativeLink",
|
|
14
13
|
};
|
|
15
14
|
const BODY_EDITOR_ID = "managed-pages-page-body";
|
|
16
15
|
function stringValue(value) {
|
|
@@ -18,7 +18,8 @@ export const runListQuery = defineQueryHandler({
|
|
|
18
18
|
const { counts, outcome } = viewRun(run, query.user);
|
|
19
19
|
return {
|
|
20
20
|
id: run.id,
|
|
21
|
-
|
|
21
|
+
// Fixed width: pageRows sorts this as a string, and a trimmed fraction ("…20Z" vs "…20.001Z") sorts wrong.
|
|
22
|
+
requestedAt: run.requestedAt.toString({ fractionalSecondDigits: 6 }),
|
|
22
23
|
triggeredByUserId: run.triggeredByUserId,
|
|
23
24
|
triggeredByName: starterNames.get(run.triggeredByUserId) ?? run.triggeredByUserId,
|
|
24
25
|
status: run.status,
|
|
@@ -1,15 +1,13 @@
|
|
|
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 { type ResultCounts, type RunOutcome, type VisibleProbeResult } from "./visible-results.ts";
|
|
4
|
-
type InstantLike = {
|
|
5
|
-
readonly toString: () => string;
|
|
6
|
-
};
|
|
7
5
|
export type RunRow = {
|
|
8
6
|
readonly id: string;
|
|
9
7
|
readonly status: string;
|
|
10
|
-
readonly requestedAt:
|
|
11
|
-
readonly startedAt:
|
|
12
|
-
readonly finishedAt:
|
|
8
|
+
readonly requestedAt: Temporal.Instant;
|
|
9
|
+
readonly startedAt: Temporal.Instant | null;
|
|
10
|
+
readonly finishedAt: Temporal.Instant | null;
|
|
13
11
|
readonly triggeredByUserId: string;
|
|
14
12
|
readonly durationMs: number | null;
|
|
15
13
|
readonly results: unknown;
|
|
@@ -22,4 +20,3 @@ export type RunView = {
|
|
|
22
20
|
export declare const HISTORY_LIMIT = 100;
|
|
23
21
|
export declare function fetchLatestRun(db: TenantDb): Promise<RunRow | undefined>;
|
|
24
22
|
export declare function viewRun(run: RunRow, user: SessionUser): RunView;
|
|
25
|
-
export {};
|
|
@@ -2,7 +2,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
2
2
|
import { isResourceImageContentType } from "../shared/index.js";
|
|
3
3
|
import { rewriteStepsBlock } from "./compile-steps-block.js";
|
|
4
4
|
import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
|
|
5
|
-
import { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_TEXT_ALIGNMENTS,
|
|
5
|
+
import { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_TEXT_ALIGNMENTS, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
|
|
6
6
|
// Private-use characters mark where a Handlebars expression goes. Author text
|
|
7
7
|
// is scrubbed of them first and every `{`/`}` is entity-encoded before the
|
|
8
8
|
// markers are swapped for real expressions, so the only Handlebars syntax in
|
|
@@ -120,7 +120,7 @@ function rewriteLink(element, state) {
|
|
|
120
120
|
element.setAttribute("href", "#");
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
-
else if (!
|
|
123
|
+
else if (!isAllowedDocumentUrlHref(href)) {
|
|
124
124
|
element.setAttribute("href", "#");
|
|
125
125
|
}
|
|
126
126
|
}
|
|
@@ -2,7 +2,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
2
2
|
import { generateHTML, generateJSON } from "@tiptap/html/server";
|
|
3
3
|
import { Window } from "happy-dom";
|
|
4
4
|
import { DOCUMENT_HTML_KINDS } from "./web/constants.js";
|
|
5
|
-
import { DOCUMENT_CONDITION_ATTRIBUTE, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
|
|
5
|
+
import { DOCUMENT_CONDITION_ATTRIBUTE, documentExtensions, isAllowedDocumentLinkHref, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
|
|
6
6
|
import { isDocumentStepKey } from "./web/document-steps.js";
|
|
7
7
|
export function violation(code, detail, message) {
|
|
8
8
|
return { code, detail, message };
|
|
@@ -38,7 +38,7 @@ function scanScriptAndHandlers(tag, element, out) {
|
|
|
38
38
|
function scanAnchor(element, out) {
|
|
39
39
|
const href = element.getAttribute("href");
|
|
40
40
|
if (href !== null && !isAllowedDocumentLinkHref(href)) {
|
|
41
|
-
out.push(violation("disallowed_link_href", href, `Link target "${href}" is not allowed; use https, http, mailto, tel or a {{placeholder}}`));
|
|
41
|
+
out.push(violation("disallowed_link_href", href, `Link target "${href}" is not allowed; use https, http, mailto, tel, a relative path or a {{placeholder}}`));
|
|
42
42
|
}
|
|
43
43
|
if (element.hasAttribute("data-button") && !isDocumentPlaceholderHref(href ?? "")) {
|
|
44
44
|
out.push(violation("button_href_not_placeholder", href ?? "", `Button target "${href ?? ""}" must be a {{placeholder}}`));
|
|
@@ -96,7 +96,7 @@ function scanUrlAttributes(tag, element, out) {
|
|
|
96
96
|
if (SVG_IMAGE_TAGS.has(tag) && name.endsWith("href")) {
|
|
97
97
|
out.push(violation("external_image_source", value, `Images must reference an uploaded resource via data-resource; image source "${value}" is not allowed`));
|
|
98
98
|
}
|
|
99
|
-
else if (tag === "base" || !
|
|
99
|
+
else if (tag === "base" || !isAllowedDocumentUrlHref(value)) {
|
|
100
100
|
out.push(violation("disallowed_link_href", value, `Link target "${value}" is not allowed; use https, http, mailto, tel or a {{placeholder}}`));
|
|
101
101
|
}
|
|
102
102
|
}
|
|
@@ -24,5 +24,5 @@ export { collectTemplateUsage, type TemplateUsageEntry } from "./template-usage.
|
|
|
24
24
|
export { hasPlaceholderProblems, resolveTemplateVariables, SYSTEM_PLACEHOLDERS, systemPlaceholdersFor, type TemplateValidation, validateTemplatePlaceholders, } from "./template-validation.ts";
|
|
25
25
|
export { type UserContentEntryRow, userContentEntriesTable, userContentEntryEntity, } from "./user-content-table.ts";
|
|
26
26
|
export { CONTENT_FORMATS, type ContentFormat, DEFAULT_MAIL_LAYOUT_SLUG, DEFAULT_PDF_LAYOUT_SLUG, DOCUMENT_HTML_KINDS, type DocumentHtmlKind, FALLBACK_LOCALE, NON_RENDER_KINDS, type NonRenderKind, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, type PdfOrientation, type PdfPageFormat, RENDER_KINDS, type RenderKind, SYSTEM_TENANT_ID, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES, TEXT_BLOCK_KIND, type TemplateKind, type TemplateScope, type TemplateStatus, UPSERT_KINDS, type UpsertKind, } from "./web/constants.ts";
|
|
27
|
-
export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, type DocumentTextAlignment, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.ts";
|
|
27
|
+
export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, type DocumentTextAlignment, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.ts";
|
|
28
28
|
export { TEMPLATE_RESOLVER_FEATURE, TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "./web/qualified-names.ts";
|
|
@@ -21,5 +21,5 @@ export { collectTemplateUsage } from "./template-usage.js";
|
|
|
21
21
|
export { hasPlaceholderProblems, resolveTemplateVariables, SYSTEM_PLACEHOLDERS, systemPlaceholdersFor, validateTemplatePlaceholders, } from "./template-validation.js";
|
|
22
22
|
export { userContentEntriesTable, userContentEntryEntity, } from "./user-content-table.js";
|
|
23
23
|
export { CONTENT_FORMATS, DEFAULT_MAIL_LAYOUT_SLUG, DEFAULT_PDF_LAYOUT_SLUG, DOCUMENT_HTML_KINDS, FALLBACK_LOCALE, NON_RENDER_KINDS, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, RENDER_KINDS, SYSTEM_TENANT_ID, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES, TEXT_BLOCK_KIND, UPSERT_KINDS, } from "./web/constants.js";
|
|
24
|
-
export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
|
|
24
|
+
export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
|
|
25
25
|
export { TEMPLATE_RESOLVER_FEATURE, TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "./web/qualified-names.js";
|
|
@@ -2,6 +2,7 @@ import { Extension, type Extensions, Node } from "@tiptap/core";
|
|
|
2
2
|
import { isDocumentReferenceName } from "./document-reference-name.ts";
|
|
3
3
|
export { isDocumentReferenceName };
|
|
4
4
|
export declare function isDocumentPlaceholderHref(href: string): boolean;
|
|
5
|
+
export declare function isAllowedDocumentUrlHref(href: string): boolean;
|
|
5
6
|
export declare function isAllowedDocumentLinkHref(href: string): boolean;
|
|
6
7
|
export declare function isDocumentResourceKey(value: string): boolean;
|
|
7
8
|
export declare const DOCUMENT_NODE_NAMES: readonly ["variable", "resource", "button", "divider", "pageBreak", "steps", "images"];
|
|
@@ -40,15 +40,30 @@ function hasAllowedLinkScheme(href) {
|
|
|
40
40
|
return isParsableWebUrl(href, "http:");
|
|
41
41
|
return ALLOWED_LINK_SCHEMES.some((scheme) => href.startsWith(scheme));
|
|
42
42
|
}
|
|
43
|
+
// Relative references are matched by their literal leading character so a
|
|
44
|
+
// scheme can never hide in them: a value starting with a letter ("x:y",
|
|
45
|
+
// "javascript:") is not relative. Protocol-relative "//host" and any backslash
|
|
46
|
+
// (browsers read "/\\host" as "//host") would leave the site.
|
|
47
|
+
const RELATIVE_HREF_PREFIXES = ["/", "./", "../", "#", "?"];
|
|
48
|
+
function isRelativeHref(href) {
|
|
49
|
+
if (href.includes("\\") || href.startsWith("//"))
|
|
50
|
+
return false;
|
|
51
|
+
return RELATIVE_HREF_PREFIXES.some((prefix) => href.startsWith(prefix));
|
|
52
|
+
}
|
|
43
53
|
// Exact lowercase prefix on the unchanged value: no trim, no case folding, so
|
|
44
54
|
// "JaVaScRiPt:", "HTTPS://" or a leading tab can never slip through as a
|
|
45
55
|
// scheme variant. Markup and control characters are refused outright because
|
|
46
56
|
// downstream parsers (MJML, PDF renderer) re-read the serialized attribute.
|
|
47
|
-
export function
|
|
57
|
+
export function isAllowedDocumentUrlHref(href) {
|
|
48
58
|
if (hasUnsafeUrlCharacter(href))
|
|
49
59
|
return false;
|
|
50
60
|
return hasAllowedLinkScheme(href) || isDocumentPlaceholderHref(href);
|
|
51
61
|
}
|
|
62
|
+
// Anchors only: other URL attributes (form action, svg href) resolve against the
|
|
63
|
+
// page and must stay on explicit schemes or placeholders.
|
|
64
|
+
export function isAllowedDocumentLinkHref(href) {
|
|
65
|
+
return isAllowedDocumentUrlHref(href) || (!hasUnsafeUrlCharacter(href) && isRelativeHref(href));
|
|
66
|
+
}
|
|
52
67
|
export function isDocumentResourceKey(value) {
|
|
53
68
|
return RESOURCE_KEY.test(value);
|
|
54
69
|
}
|
|
@@ -18,7 +18,7 @@ export const defaultTranslations = {
|
|
|
18
18
|
"template-resolver.documentEditor.insert": "Insert",
|
|
19
19
|
"template-resolver.documentEditor.insertPlaceholder": "Placeholder",
|
|
20
20
|
"template-resolver.documentEditor.invalidImageAlt": "The alt text must not contain < or >.",
|
|
21
|
-
"template-resolver.documentEditor.invalidLink": "This link target is not allowed. Use https, http, mailto, tel or a placeholder.",
|
|
21
|
+
"template-resolver.documentEditor.invalidLink": "This link target is not allowed. Use https, http, mailto, tel, a relative path or a placeholder.",
|
|
22
22
|
"template-resolver.documentEditor.invalidResourceKey": "This resource key is not valid.",
|
|
23
23
|
"template-resolver.documentEditor.link": "Link",
|
|
24
24
|
"template-resolver.documentEditor.linkHref": "Link target",
|
|
@@ -55,7 +55,7 @@ export const defaultTranslations = {
|
|
|
55
55
|
"template-resolver.editor.saved": "Saved.",
|
|
56
56
|
"templateResolver.errors.document.imagesBlockPdfOnly": "Photo blocks are only allowed in PDF documents.",
|
|
57
57
|
"templateResolver.errors.document.buttonHrefNotPlaceholder": "The button target “{detail}” must be a placeholder.",
|
|
58
|
-
"templateResolver.errors.document.disallowedLinkHref": "Link target “{detail}” is not allowed; use https, http, mailto, tel or a placeholder.",
|
|
58
|
+
"templateResolver.errors.document.disallowedLinkHref": "Link target “{detail}” is not allowed; use https, http, mailto, tel, a relative path or a placeholder.",
|
|
59
59
|
"templateResolver.errors.document.documentRequiresHtmlFormat": "This template kind requires the html format, got “{detail}”.",
|
|
60
60
|
"templateResolver.errors.document.layoutRequiresMjmlFormat": "Mail layouts require the mjml format, got “{detail}”.",
|
|
61
61
|
"templateResolver.errors.document.layoutInvalidStructure": "The layout may only contain the header, content, signature and footer regions: {detail}.",
|
|
@@ -26,9 +26,10 @@ export function useCollectionEntryCreate(target, tenantId) {
|
|
|
26
26
|
const [content, setContent] = useState("");
|
|
27
27
|
const slugValid = slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug);
|
|
28
28
|
const disabled = submitting || !canWrite || collectionId === undefined;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
const entryLabel = collection?.nav.entryLabel;
|
|
30
|
+
const headerTitle = entryLabel !== undefined
|
|
31
|
+
? t("template-resolver.editor.newEntryTitle", { label: t(entryLabel) })
|
|
32
|
+
: t("template-resolver.editor.newEntry");
|
|
32
33
|
const create = async () => {
|
|
33
34
|
// skip: nothing to write to, or the slug is invalid (the field already shows the issue)
|
|
34
35
|
if (collectionId === undefined || !slugValid)
|