@cosmicdrift/kumiko-bundled-features 0.388.0 → 0.389.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/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/tool-dispatch.d.ts +8 -3
- package/dist/agent-tools/tool-dispatch.js +36 -25
- package/dist/audit/feature.js +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
- package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
- package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
- package/dist/auth-email-password/magic-link-mail.js +7 -3
- package/dist/auth-email-password/signup-activation.d.ts +1 -0
- package/dist/auth-email-password/signup-activation.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +2 -2
- package/dist/auth-email-password/web/auth-client.js +8 -4
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
- package/dist/auth-email-password/web/auth-redirect.js +20 -25
- package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
- package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
- package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
- package/dist/auth-email-password/web/index.d.ts +1 -0
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/login-screen.js +11 -6
- package/dist/auth-email-password/web/reset-password-screen.js +7 -1
- package/dist/auth-email-password/web/return-path.d.ts +4 -0
- package/dist/auth-email-password/web/return-path.js +34 -0
- package/dist/auth-email-password/web/session.js +6 -1
- package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
- package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
- package/dist/auth-email-password/web/signup-screen.js +8 -3
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +3 -7
- package/dist/auth-mfa/mfa-status-checker.js +5 -3
- package/dist/cap-counter/cap-reservation.js +15 -6
- package/dist/cap-counter/feature.js +1 -0
- package/dist/cap-overview/feature.js +1 -0
- package/dist/config/handlers/set.write.js +12 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.js +1 -1
- package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
- package/dist/delivery/feature.js +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/system-channel-context.d.ts +15 -0
- package/dist/delivery/system-channel-context.js +5 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
- package/dist/document-ingest-foundation/events.d.ts +11 -0
- package/dist/document-ingest-foundation/events.js +11 -0
- package/dist/document-ingest-foundation/index.d.ts +2 -1
- package/dist/document-ingest-foundation/index.js +2 -1
- package/dist/feature-toggles/feature.js +1 -0
- package/dist/host-routing/validate-options.js +3 -0
- package/dist/inbound-mail-foundation/feature.js +1 -0
- package/dist/jobs/feature.js +1 -0
- package/dist/ledger/web/index.js +1 -1
- package/dist/legal-pages/seed.d.ts +3 -1
- package/dist/legal-pages/seed.js +2 -2
- package/dist/mail-transport-smtp/config-keys.js +1 -1
- package/dist/mail-transport-smtp/feature.js +6 -5
- package/dist/managed-pages/constants.d.ts +1 -0
- package/dist/managed-pages/constants.js +1 -0
- package/dist/managed-pages/feature.js +3 -1
- package/dist/managed-pages/index.d.ts +1 -0
- package/dist/managed-pages/index.js +1 -0
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/personal-access-tokens/feature.js +1 -0
- package/dist/secrets/write-gate.js +8 -2
- package/dist/sessions/feature.js +1 -0
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/session-callbacks.d.ts +0 -2
- package/dist/sessions/session-callbacks.js +1 -3
- package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
- package/dist/shared/purge-files-reaching-subject.js +19 -2
- package/dist/shared/run-in-sub-transaction.js +5 -5
- package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
- package/dist/step-dispatcher/mail-runner.d.ts +1 -0
- package/dist/tags/feature.js +1 -0
- package/dist/template-resolver/document-ops.d.ts +5 -0
- package/dist/template-resolver/document-ops.js +18 -2
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
- package/dist/template-resolver/handlers/layout-source.query.js +26 -6
- package/dist/template-resolver/handlers/overview.query.js +15 -7
- package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
- package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
- package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
- package/dist/template-resolver/seeding.d.ts +1 -0
- package/dist/template-resolver/seeding.js +5 -2
- package/dist/template-resolver/store-template-resource.js +46 -6
- package/dist/template-resolver/web/i18n.js +3 -0
- package/dist/template-resolver/web/qualified-names.d.ts +1 -0
- package/dist/template-resolver/web/qualified-names.js +1 -0
- package/dist/tenant/feature.js +1 -0
- package/dist/tenant/handlers/team-list.query.js +3 -3
- package/dist/tenant-lifecycle/stages.js +22 -1
- package/dist/user/feature.js +1 -0
- package/dist/user-data-rights/feature.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -1
- package/dist/user-data-rights/index.js +1 -1
- package/dist/user-data-rights/web/constants.d.ts +6 -0
- package/dist/user-data-rights/web/constants.js +6 -0
- package/dist/user-data-rights/web/index.d.ts +1 -0
- package/dist/user-data-rights/web/index.js +1 -0
- package/dist/waitlist/feature.js +1 -0
- package/dist/waitlist/handlers/submit.write.js +13 -9
- package/dist/waitlist/import.d.ts +2 -1
- package/dist/waitlist/import.js +22 -2
- package/dist/waitlist/legacy-migrate.js +2 -2
- package/dist/waitlist/options.d.ts +2 -1
- package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
- package/package.json +11 -11
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/cap-counter/changes.json +12 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +12 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/host-routing/changes.json +6 -0
- package/src/mail-transport-smtp/changes.json +6 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/sessions/changes.json +7 -0
- package/src/step-dispatcher/changes.json +6 -0
- package/src/template-resolver/changes.json +36 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/user-data-rights/changes.json +12 -0
- package/src/waitlist/changes.json +18 -0
|
@@ -11,6 +11,9 @@ function validateRedirects(options) {
|
|
|
11
11
|
if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
|
|
12
12
|
throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
|
|
13
13
|
}
|
|
14
|
+
if (rule.from === "/api" || rule.from.startsWith("/api/")) {
|
|
15
|
+
throw new Error(`host-routing: redirect from must not be an /api path, it would never fire, got "${rule.from}"`);
|
|
16
|
+
}
|
|
14
17
|
if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
|
|
15
18
|
throw new Error(`host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`);
|
|
16
19
|
}
|
|
@@ -58,6 +58,7 @@ import { mailAccountEditScreen, mailAccountListScreen } from "./screens.js";
|
|
|
58
58
|
import { INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON, inboundMessageTenantDestroyHook, mailAccountTenantDestroyHook, mailThreadTenantDestroyHook, } from "./tenant-destroy-hook.js";
|
|
59
59
|
export function createInboundMailFoundationFeature() {
|
|
60
60
|
return defineFeature(INBOUND_MAIL_FOUNDATION_FEATURE, (r) => {
|
|
61
|
+
r.adoptsBundledLists();
|
|
61
62
|
r.describe("Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.");
|
|
62
63
|
r.uiHints({
|
|
63
64
|
displayLabel: "Inbound Mail · Foundation",
|
package/dist/jobs/feature.js
CHANGED
|
@@ -25,6 +25,7 @@ export function createJobsFeature(options = {}) {
|
|
|
25
25
|
throw new Error(`createJobsFeature: readRoles must not contain "anonymous" — job status and failures are only readable by members of the tenant.`);
|
|
26
26
|
}
|
|
27
27
|
return defineFeature("jobs", (r) => {
|
|
28
|
+
r.adoptsBundledLists();
|
|
28
29
|
r.describe("Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops `queued` rows older than `retentionDays`.");
|
|
29
30
|
r.uiHints({
|
|
30
31
|
displayLabel: "Jobs · Audit & Operator UI",
|
package/dist/ledger/web/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Client-safe ledger surface: QN constants, pure recurring helpers
|
|
2
2
|
// (projection + Soll/Ist merge), and Zod payload schemas for client-side
|
|
3
3
|
// pre-validation before dispatch. The full `../ledger` entry also re-exports
|
|
4
|
-
// the feature/handlers/executor, which pull
|
|
4
|
+
// the feature/handlers/executor, which pull the DB driver; importing it in a
|
|
5
5
|
// browser bundle fails on Node builtins. Client screens import dispatch QNs and
|
|
6
6
|
// pure forecast/merge helpers from this module, then dispatch via the renderer's
|
|
7
7
|
// useDispatcher.
|
|
@@ -9,4 +9,6 @@ export type LegalContentSeedSource = {
|
|
|
9
9
|
export type LegalContentSeedFn = (deps: {
|
|
10
10
|
readonly db: DbConnection;
|
|
11
11
|
}) => Promise<void>;
|
|
12
|
-
export declare function legalContentSeed(source: LegalContentSeedSource
|
|
12
|
+
export declare function legalContentSeed(source: LegalContentSeedSource, options?: {
|
|
13
|
+
readonly folder?: string;
|
|
14
|
+
}): LegalContentSeedFn;
|
package/dist/legal-pages/seed.js
CHANGED
|
@@ -28,11 +28,11 @@ async function loadBlocksFromJson(path) {
|
|
|
28
28
|
}
|
|
29
29
|
// Boot seed for runProdApp's `seeds: [...]`. The JSON template is authoritative
|
|
30
30
|
// (ifExists "update"), so legally required text changes reach rows seeded once.
|
|
31
|
-
export function legalContentSeed(source) {
|
|
31
|
+
export function legalContentSeed(source, options) {
|
|
32
32
|
return async ({ db }) => {
|
|
33
33
|
const blocks = "blocks" in source
|
|
34
34
|
? legalContentBlocksSchema.parse(source.blocks)
|
|
35
35
|
: await loadBlocksFromJson(source.path);
|
|
36
|
-
await seedLegalContentFromJson(db, blocks);
|
|
36
|
+
await seedLegalContentFromJson(db, blocks, options);
|
|
37
37
|
};
|
|
38
38
|
}
|
|
@@ -4,6 +4,7 @@ import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine
|
|
|
4
4
|
const tenantAdminWrite = access.withSystem(access.roles("TenantAdmin"));
|
|
5
5
|
const tenantAdminRead = access.roles("TenantAdmin", "SystemAdmin");
|
|
6
6
|
// required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
|
|
7
|
+
// authUser stays optional: empty means an unauthenticated relay.
|
|
7
8
|
export const SMTP_CONFIG_KEYS = {
|
|
8
9
|
host: createTenantConfig("text", {
|
|
9
10
|
required: true,
|
|
@@ -39,7 +40,6 @@ export const SMTP_CONFIG_KEYS = {
|
|
|
39
40
|
},
|
|
40
41
|
}),
|
|
41
42
|
authUser: createTenantConfig("text", {
|
|
42
|
-
required: true,
|
|
43
43
|
default: "",
|
|
44
44
|
write: tenantAdminWrite,
|
|
45
45
|
read: tenantAdminRead,
|
|
@@ -76,8 +76,7 @@ export function createMailTransportSmtpFeature() {
|
|
|
76
76
|
return `${plaintext.slice(0, 3)}...${plaintext.slice(-2)}`;
|
|
77
77
|
},
|
|
78
78
|
scope: "tenant",
|
|
79
|
-
//
|
|
80
|
-
required: true,
|
|
79
|
+
// Only needed once authUser is set (readPassword throws then), so readiness must not demand it.
|
|
81
80
|
});
|
|
82
81
|
const configKeys = r.config({ keys: SMTP_CONFIG_KEYS });
|
|
83
82
|
r.translations({ keys: MAIL_TRANSPORT_SMTP_I18N });
|
|
@@ -126,8 +125,10 @@ async function buildSmtpTransport(ctx, tenantId) {
|
|
|
126
125
|
const port = requireDefined(await ctxConfig(sharedMailTransportSmtpFeature.exports.configKeys.port), FEATURE_NAME, "port"); // @cast-boundary engine-payload
|
|
127
126
|
const secure = requireDefined(await ctxConfig(sharedMailTransportSmtpFeature.exports.configKeys.secure), FEATURE_NAME, "secure"); // @cast-boundary engine-payload
|
|
128
127
|
const from = requireNonEmpty(await ctxConfig(sharedMailTransportSmtpFeature.exports.configKeys.from), FEATURE_NAME, "from", SMTP_HINT);
|
|
129
|
-
const authUser =
|
|
130
|
-
const
|
|
128
|
+
const authUser = (await ctxConfig(sharedMailTransportSmtpFeature.exports.configKeys.authUser))?.trim();
|
|
129
|
+
const auth = authUser === undefined || authUser === ""
|
|
130
|
+
? undefined
|
|
131
|
+
: { user: authUser, pass: await readPassword(ctx, tenantId) };
|
|
131
132
|
let target;
|
|
132
133
|
try {
|
|
133
134
|
target = await resolveMailConnectTarget(host, {
|
|
@@ -155,7 +156,7 @@ async function buildSmtpTransport(ctx, tenantId) {
|
|
|
155
156
|
port,
|
|
156
157
|
secure,
|
|
157
158
|
from,
|
|
158
|
-
auth
|
|
159
|
+
...(auth && { auth }),
|
|
159
160
|
...(target.servername && { servername: target.servername }),
|
|
160
161
|
});
|
|
161
162
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const MANAGED_PAGES_FEATURE: "managed-pages";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MANAGED_PAGES_FEATURE = "managed-pages";
|
|
@@ -4,6 +4,7 @@ import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
4
4
|
import { cachedSecurePageResponse, EMPTY_BRANDING, renderSafeMarkdown, wrapInLayout, } from "../page-render/index.js";
|
|
5
5
|
import { rateLimitedTextResponse } from "../shared/index.js";
|
|
6
6
|
import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.js";
|
|
7
|
+
import { MANAGED_PAGES_FEATURE } from "./constants.js";
|
|
7
8
|
import { createBrandingQuery } from "./handlers/branding.query.js";
|
|
8
9
|
import { bySlugQuery } from "./handlers/by-slug.query.js";
|
|
9
10
|
import { byTenantPublishedQuery } from "./handlers/by-tenant-published.query.js";
|
|
@@ -79,7 +80,8 @@ export function createManagedPagesFeature(opts) {
|
|
|
79
80
|
const basePath = opts.basePath ?? "/p";
|
|
80
81
|
const defaultLang = opts.defaultLang ?? "en";
|
|
81
82
|
const allowCustomCss = opts.allowCustomCss ?? false;
|
|
82
|
-
return defineFeature(
|
|
83
|
+
return defineFeature(MANAGED_PAGES_FEATURE, (r) => {
|
|
84
|
+
r.adoptsBundledLists();
|
|
83
85
|
r.describe("Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.");
|
|
84
86
|
r.uiHints({
|
|
85
87
|
displayLabel: "Managed Pages · Public CMS",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type BrandingTokens, brandingHeaderHtml, brandingStyleBlock, EMPTY_BRANDING, sanitizeTenantCss, TENANT_CONTENT_ATTR, tenantStyleBlock, } from "../page-render/index.ts";
|
|
2
2
|
export { BRANDING_QN, MANAGED_PAGES_CSS_FEATURE } from "./branding.ts";
|
|
3
|
+
export { MANAGED_PAGES_FEATURE } from "./constants.ts";
|
|
3
4
|
export { createManagedPagesCssFeature } from "./css-gate.ts";
|
|
4
5
|
export { createManagedPagesFeature, type ManagedPagesOptions, type ManagedPagesWrapLayout, } from "./feature.ts";
|
|
5
6
|
export { type PageRow, pageEntity, pagesTable } from "./table.ts";
|
|
@@ -13,6 +13,7 @@ export { brandingHeaderHtml, brandingStyleBlock, EMPTY_BRANDING, sanitizeTenantC
|
|
|
13
13
|
// (`config:write:set`) — single source for the `managed-pages:config:branding-*`
|
|
14
14
|
// strings, so apps + the per-tenant migration never hardcode them.
|
|
15
15
|
export { BRANDING_QN, MANAGED_PAGES_CSS_FEATURE } from "./branding.js";
|
|
16
|
+
export { MANAGED_PAGES_FEATURE } from "./constants.js";
|
|
16
17
|
export { createManagedPagesCssFeature } from "./css-gate.js";
|
|
17
18
|
export { createManagedPagesFeature, } from "./feature.js";
|
|
18
19
|
export { pageEntity, pagesTable } from "./table.js";
|
|
@@ -51,7 +51,7 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS, entr
|
|
|
51
51
|
let authorName = null;
|
|
52
52
|
try {
|
|
53
53
|
// read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
|
|
54
|
-
//
|
|
54
|
+
// Postgres aborts the whole tx after any error inside it, even one that's
|
|
55
55
|
// caught — so the lookup runs in a savepoint where available, otherwise directly
|
|
56
56
|
// (pool statements are their own units).
|
|
57
57
|
authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(), async (sp) => {
|
|
@@ -41,6 +41,7 @@ export function bindPatAutoRevokeOnPasswordChangeFromFeature(feature) {
|
|
|
41
41
|
export function createPersonalAccessTokensFeature(options) {
|
|
42
42
|
const { scopes } = options;
|
|
43
43
|
return defineFeature(PAT_FEATURE, (r) => {
|
|
44
|
+
r.adoptsBundledLists();
|
|
44
45
|
r.describe("Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — registered as an auth-foundation tokenVerifier provider, resolved generically by the middleware. Pass { toggleable: { default: false } } to tier-gate the whole feature.");
|
|
45
46
|
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
46
47
|
// Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
|
|
@@ -6,6 +6,10 @@ function unknownKeyFailure(key) {
|
|
|
6
6
|
failure: writeFailure(new NotFoundError("secretKey", key, { i18nKey: SECRETS_ERROR_KEYS.unknownKey })),
|
|
7
7
|
};
|
|
8
8
|
}
|
|
9
|
+
function isUnderMountedFeaturePrefix(registry, key) {
|
|
10
|
+
const separator = key.indexOf(":");
|
|
11
|
+
return separator > 0 && registry.getFeature(key.slice(0, separator)) !== undefined;
|
|
12
|
+
}
|
|
9
13
|
function namespaceAcceptsKey(namespace, key) {
|
|
10
14
|
const suffix = key.slice(namespace.qualifiedPrefix.length);
|
|
11
15
|
if (suffix.length === 0)
|
|
@@ -16,13 +20,15 @@ function namespaceAcceptsKey(namespace, key) {
|
|
|
16
20
|
// are writable through the API, so a tenant admin cannot plant arbitrary rows
|
|
17
21
|
// next to the feature-owned ones. writeRoles narrows the handler access
|
|
18
22
|
// further for a key or a whole namespace. Delete passes allowUndeclared: it
|
|
19
|
-
// creates nothing, and legacy rows under since-undeclared keys must stay removable
|
|
23
|
+
// creates nothing, and legacy rows under since-undeclared keys must stay removable,
|
|
24
|
+
// unless the key sits under a mounted feature's prefix: those rows belong to the
|
|
25
|
+
// feature and must not be removable by a tenant admin outside its writeRoles.
|
|
20
26
|
export function checkSecretKeyWrite(registry, userRoles, key, options = {}) {
|
|
21
27
|
const keyDef = registry.getSecretKey(key);
|
|
22
28
|
const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
|
|
23
29
|
if (!keyDef &&
|
|
24
30
|
!(namespace && namespaceAcceptsKey(namespace, key)) &&
|
|
25
|
-
options.allowUndeclared !== true) {
|
|
31
|
+
(options.allowUndeclared !== true || isUnderMountedFeaturePrefix(registry, key))) {
|
|
26
32
|
return unknownKeyFailure(key);
|
|
27
33
|
}
|
|
28
34
|
const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;
|
package/dist/sessions/feature.js
CHANGED
|
@@ -48,6 +48,7 @@ export function bindAutoRevokeFromFeature(feature) {
|
|
|
48
48
|
export function createSessionsFeature(options) {
|
|
49
49
|
return defineFeature("sessions", (r) => {
|
|
50
50
|
const adminRoles = access[options?.adminAccess ?? "admin"];
|
|
51
|
+
r.adoptsBundledLists();
|
|
51
52
|
r.describe("Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.");
|
|
52
53
|
r.uiHints({
|
|
53
54
|
displayLabel: "Sessions · Server-side Logout",
|
package/dist/sessions/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ export type { BindAutoRevokeOnPasswordChange, SessionsFeatureOptions } from "./f
|
|
|
3
3
|
export { bindAutoRevokeFromFeature, createSessionsFeature } from "./feature.ts";
|
|
4
4
|
export { userSessionEntity, userSessionTable } from "./schema/user-session.ts";
|
|
5
5
|
export type { SessionCallbacks, SessionCallbacksOptions, SessionMassRevoker, } from "./session-callbacks.ts";
|
|
6
|
-
export { createSessionCallbacks
|
|
6
|
+
export { createSessionCallbacks } from "./session-callbacks.ts";
|
|
7
7
|
export type { SessionRevokedPayload } from "./session-revoked-event.ts";
|
|
8
8
|
export { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, } from "./session-revoked-event.ts";
|
package/dist/sessions/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { DEFAULT_SESSION_CACHE_TTL_MS, DEFAULT_SESSION_EXPIRY_MS, SESSION_MINE_SCREEN_ID, SESSIONS_FEATURE, SessionErrors, SessionHandlers, SessionQueries, } from "./constants.js";
|
|
2
2
|
export { bindAutoRevokeFromFeature, createSessionsFeature } from "./feature.js";
|
|
3
3
|
export { userSessionEntity, userSessionTable } from "./schema/user-session.js";
|
|
4
|
-
export { createSessionCallbacks
|
|
4
|
+
export { createSessionCallbacks } from "./session-callbacks.js";
|
|
5
5
|
export { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, } from "./session-revoked-event.js";
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { SessionChecker, SessionCreator, SessionMassRevoker, SessionRevoker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
export type { SessionMassRevoker } from "@cosmicdrift/kumiko-framework/api";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import { isPrincipalBlocked } from "../user/index.ts";
|
|
5
|
-
export { isPrincipalBlocked };
|
|
6
4
|
export type SessionAllOthersRevoker = (userId: string, currentSid: string | undefined) => Promise<number>;
|
|
7
5
|
export type SessionCallbacksOptions = {
|
|
8
6
|
readonly db: DbConnection;
|
|
@@ -5,12 +5,10 @@ import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
6
|
import { encryptForDirectWrite } from "../shared/index.js";
|
|
7
7
|
import { tenantMembershipsTable } from "../tenant/index.js";
|
|
8
|
-
import {
|
|
8
|
+
import { isPrincipalRowBlocked, userTable } from "../user/index.js";
|
|
9
9
|
import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS, USER_SESSION_ENTITY_NAME, } from "./constants.js";
|
|
10
10
|
import { userSessionEntity, userSessionTable } from "./schema/user-session.js";
|
|
11
11
|
import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN, sessionRevokedSchema, } from "./session-revoked-event.js";
|
|
12
|
-
// Re-exported so existing `../sessions` importers keep working unchanged.
|
|
13
|
-
export { isPrincipalBlocked };
|
|
14
12
|
function settleLookup(lookup) {
|
|
15
13
|
return lookup.then((value) => ({ ok: true, value }), () => ({ ok: false }));
|
|
16
14
|
}
|
|
@@ -7,6 +7,7 @@ export declare class FileSweepError extends Error {
|
|
|
7
7
|
readonly entityName: string;
|
|
8
8
|
constructor(code: Exclude<FileRefPurgeOutcome, "purged">, entityName: string);
|
|
9
9
|
}
|
|
10
|
+
export declare const PURGE_CANDIDATE_PAGE_SIZE = 200;
|
|
10
11
|
export type DeleteFileBytes = (storageKey: string) => Promise<readonly string[]>;
|
|
11
12
|
export type MissingStoragePolicy = {
|
|
12
13
|
readonly mode: "fail";
|
|
@@ -70,6 +70,24 @@ function fieldsResolvingToSubject(fileEntity, row, rowTenantId, subjectKey) {
|
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
|
+
// A user owning many records must not load them all at once; the id cursor keeps each page
|
|
74
|
+
// bounded while rows purged in place stay out of the way.
|
|
75
|
+
export const PURGE_CANDIDATE_PAGE_SIZE = 200;
|
|
76
|
+
async function* candidateRowsPaged(db, table, where) {
|
|
77
|
+
let cursor;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const pageWhere = cursor === undefined ? where : { ...where, id: { gt: cursor } };
|
|
80
|
+
const page = await selectMany(db, table, pageWhere, {
|
|
81
|
+
orderBy: { col: "id", direction: "asc" },
|
|
82
|
+
limit: PURGE_CANDIDATE_PAGE_SIZE,
|
|
83
|
+
});
|
|
84
|
+
yield* page;
|
|
85
|
+
// skip: a short page is the last one
|
|
86
|
+
if (page.length < PURGE_CANDIDATE_PAGE_SIZE)
|
|
87
|
+
return;
|
|
88
|
+
cursor = page[page.length - 1]?.["id"];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
73
91
|
const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
74
92
|
entityName: "fileRef",
|
|
75
93
|
});
|
|
@@ -99,8 +117,7 @@ export async function purgeFilesReachingSubject(args) {
|
|
|
99
117
|
for (const fileEntity of fileEntities) {
|
|
100
118
|
const seenRowIds = new Set();
|
|
101
119
|
for (const where of candidateWheres(subject, recordTenantId, fileEntity)) {
|
|
102
|
-
const
|
|
103
|
-
for (const row of rows) {
|
|
120
|
+
for await (const row of candidateRowsPaged(db, fileEntity.table, where)) {
|
|
104
121
|
const rowId = String(row["id"]);
|
|
105
122
|
const tenantId = parseTenantId(row["tenantId"]);
|
|
106
123
|
// skip: a row without a tenant cannot own tenant-scoped file refs
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Per-unit sub-transaction (one user, one purged row), nesting-aware across both db shapes:
|
|
2
|
-
// - top-level connection (
|
|
3
|
-
// -
|
|
2
|
+
// - top-level connection (Bun.SQL pool) → `.begin` (BEGIN)
|
|
3
|
+
// - TransactionSQL (inside the dispatcher's outer tx, where every
|
|
4
4
|
// writeHandler already runs) → `.savepoint` (SAVEPOINT)
|
|
5
|
-
// A
|
|
6
|
-
// threw
|
|
5
|
+
// A transaction handle cannot begin another transaction, so the previous unconditional `.begin`
|
|
6
|
+
// threw on every user when invoked through the dispatcher
|
|
7
7
|
// (the cron path) → zero deletions in production, while direct-connection tests
|
|
8
8
|
// stayed green. Selecting the available method makes the sub-tx work in both
|
|
9
9
|
// contexts; on throw the savepoint rolls back just this user (others survive).
|
|
@@ -13,7 +13,7 @@ export async function runInSubTransaction(db, fn) {
|
|
|
13
13
|
// without asRawClient (a test-only escape hatch).
|
|
14
14
|
const runner = db;
|
|
15
15
|
// savepoint-first: empirically (Bun 1.3.14, not re-checked on 1.4.0) the
|
|
16
|
-
// surfaces are NOT mutually exclusive — a
|
|
16
|
+
// surfaces are NOT mutually exclusive — a TransactionSQL exposes begin AND
|
|
17
17
|
// savepoint, only the top-level connection has begin alone. begin-first
|
|
18
18
|
// picked the nested BEGIN inside a tx (the incident class above);
|
|
19
19
|
// savepoint-first hits the savepoint there and falls back to begin top-level.
|
|
@@ -31,9 +31,16 @@ export async function eraseDispatchKey(aggregateId) {
|
|
|
31
31
|
eraseReason: "step-dispatch-outcome-recorded",
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
const SMTP_PERMANENT_REJECTION_MIN = 500;
|
|
35
|
+
const SMTP_PERMANENT_REJECTION_MAX = 599;
|
|
36
|
+
// Without an SMTP code the adapter gave nothing to classify, so the failure counts as transient.
|
|
35
37
|
function mailResultAsDelivery(result) {
|
|
36
|
-
|
|
38
|
+
if (result.ok)
|
|
39
|
+
return result;
|
|
40
|
+
const isPermanent = result.smtpCode !== undefined &&
|
|
41
|
+
result.smtpCode >= SMTP_PERMANENT_REJECTION_MIN &&
|
|
42
|
+
result.smtpCode <= SMTP_PERMANENT_REJECTION_MAX;
|
|
43
|
+
return { ok: false, error: result.error, retryable: !isPermanent };
|
|
37
44
|
}
|
|
38
45
|
// Outcome events are plaintext and generic; the request payload's per-dispatch
|
|
39
46
|
// key is erased right after, so the PII dies with the dispatch instead of
|
|
@@ -137,7 +144,13 @@ async function sendDispatch(dispatchSpec, target) {
|
|
|
137
144
|
aggregateId: target.aggregateId,
|
|
138
145
|
errorName: err instanceof Error ? err.name : "unknown",
|
|
139
146
|
});
|
|
140
|
-
|
|
147
|
+
// A mail adapter has no idempotency key: a throw after the provider accepted the message
|
|
148
|
+
// would send it again, so only the webhook (which carries one) is retried.
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: DISPATCH_THREW_ERROR,
|
|
152
|
+
retryable: dispatchSpec.stepKind === "webhook.send",
|
|
153
|
+
};
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
156
|
async function abortOnFinalAttempt(err, ctx, recorder, aggregateId, requestedPayload) {
|
|
@@ -12,6 +12,7 @@ export type MailDispatchResult = {
|
|
|
12
12
|
} | {
|
|
13
13
|
readonly ok: false;
|
|
14
14
|
readonly error: string;
|
|
15
|
+
readonly smtpCode?: number;
|
|
15
16
|
};
|
|
16
17
|
export declare function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void;
|
|
17
18
|
export declare function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult>;
|
package/dist/tags/feature.js
CHANGED
|
@@ -30,6 +30,7 @@ import { TAGS_FEATURE_I18N } from "./i18n.js";
|
|
|
30
30
|
import { createTagEditScreen, createTagListScreen } from "./screens.js";
|
|
31
31
|
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./web/constants.js";
|
|
32
32
|
function registerTags(r, access, toggleable, ownership, parents) {
|
|
33
|
+
r.adoptsBundledLists();
|
|
33
34
|
r.describe("Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).");
|
|
34
35
|
r.uiHints({
|
|
35
36
|
displayLabel: "Tags",
|
|
@@ -163,5 +163,10 @@ export type DocumentOpsResult = {
|
|
|
163
163
|
readonly ok: false;
|
|
164
164
|
readonly issues: FieldIssue[];
|
|
165
165
|
};
|
|
166
|
+
export declare const REPLACE_TEXT_ISSUE_CODES: {
|
|
167
|
+
readonly notFound: "text_not_found";
|
|
168
|
+
readonly spansFormatting: "text_spans_formatting";
|
|
169
|
+
readonly placeholderNotReplaceable: "placeholder_not_replaceable";
|
|
170
|
+
};
|
|
166
171
|
export declare function applyDocumentOps(input: DocumentOpsInput): DocumentOpsResult;
|
|
167
172
|
export {};
|
|
@@ -283,14 +283,30 @@ function removeBlockOp(state, op, path) {
|
|
|
283
283
|
found.entry.removed = true;
|
|
284
284
|
return null;
|
|
285
285
|
}
|
|
286
|
+
export const REPLACE_TEXT_ISSUE_CODES = {
|
|
287
|
+
notFound: "text_not_found",
|
|
288
|
+
spansFormatting: "text_spans_formatting",
|
|
289
|
+
placeholderNotReplaceable: "placeholder_not_replaceable",
|
|
290
|
+
};
|
|
291
|
+
// document-blocks shows the line across formatting runs with placeholders as {{name}},
|
|
292
|
+
// so a find that is visible there but lies in no single text node needs its own explanation.
|
|
293
|
+
function textNotMatchedCode(block, find) {
|
|
294
|
+
if (!textLinesOf(block).some((line) => line.includes(find))) {
|
|
295
|
+
return REPLACE_TEXT_ISSUE_CODES.notFound;
|
|
296
|
+
}
|
|
297
|
+
return find.includes("{{")
|
|
298
|
+
? REPLACE_TEXT_ISSUE_CODES.placeholderNotReplaceable
|
|
299
|
+
: REPLACE_TEXT_ISSUE_CODES.spansFormatting;
|
|
300
|
+
}
|
|
286
301
|
function replaceTextOp(state, op, path) {
|
|
287
302
|
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
288
303
|
if ("issue" in found)
|
|
289
304
|
return found.issue;
|
|
290
305
|
const { entry } = found;
|
|
291
306
|
const count = textNodesIn([entry.node]).reduce((sum, node) => sum + occurrencesOf(node.text ?? "", op.find), 0);
|
|
292
|
-
if (count === 0)
|
|
293
|
-
return patchIssue(`${path}.find`,
|
|
307
|
+
if (count === 0) {
|
|
308
|
+
return patchIssue(`${path}.find`, textNotMatchedCode(entry.node, op.find), { find: op.find });
|
|
309
|
+
}
|
|
294
310
|
if (count > 1)
|
|
295
311
|
return patchIssue(`${path}.find`, "text_ambiguous", { find: op.find, count });
|
|
296
312
|
entry.node = replaceInContent([entry.node], op.find, op.replace)[0] ?? entry.node;
|
|
@@ -22,7 +22,7 @@ import { makeResourceLinkPdfWrite } from "./handlers/resource-link-pdf.write.js"
|
|
|
22
22
|
import { makeResourceRemoveWrite } from "./handlers/resource-remove.write.js";
|
|
23
23
|
import { makeResourceUploadWrite } from "./handlers/resource-upload.write.js";
|
|
24
24
|
import { makeSendTestWrite } from "./handlers/send-test.write.js";
|
|
25
|
-
import {
|
|
25
|
+
import { makeSendTestStatusQuery } from "./handlers/send-test-status.query.js";
|
|
26
26
|
import { makeSetWrite } from "./handlers/set.write.js";
|
|
27
27
|
import { tenantOverrideCountsQuery } from "./handlers/tenant-override-counts.query.js";
|
|
28
28
|
import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.js";
|
|
@@ -137,7 +137,7 @@ export function createTemplateResolverFeature(opts = {}) {
|
|
|
137
137
|
preview: r.queryHandler(previewQuery),
|
|
138
138
|
documentBlocks: r.queryHandler(documentBlocksQuery),
|
|
139
139
|
exportBundle: r.queryHandler(exportBundleQuery),
|
|
140
|
-
sendTestStatus: r.queryHandler(
|
|
140
|
+
sendTestStatus: r.queryHandler(makeSendTestStatusQuery(policy)),
|
|
141
141
|
};
|
|
142
142
|
for (const collection of collections) {
|
|
143
143
|
r.contentCollection({
|
|
@@ -28,4 +28,8 @@ export declare const layoutSourceQuery: import("@cosmicdrift/kumiko-types/define
|
|
|
28
28
|
}>;
|
|
29
29
|
slug: z.ZodDefault<z.ZodString>;
|
|
30
30
|
locale: z.ZodOptional<z.ZodString>;
|
|
31
|
+
scope: z.ZodOptional<z.ZodEnum<{
|
|
32
|
+
system: "system";
|
|
33
|
+
tenant: "tenant";
|
|
34
|
+
}>>;
|
|
31
35
|
}, z.core.$strip>, LayoutSourceResponse, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import { filterDeliveredTo } from "../api.js";
|
|
5
6
|
import { readLayoutRegions } from "../layout-regions.js";
|
|
@@ -8,6 +9,7 @@ import { deliveryPolicyOf } from "../template-delivery-policy.js";
|
|
|
8
9
|
import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow, } from "../template-draft.js";
|
|
9
10
|
import { readMailLayoutMeta } from "../template-meta.js";
|
|
10
11
|
import { FALLBACK_LOCALE } from "../web/constants.js";
|
|
12
|
+
import { TemplateResolverErrors } from "../web/qualified-names.js";
|
|
11
13
|
import { localeSchema, templateSlugSchema } from "./shared.js";
|
|
12
14
|
import { parseStoredJson } from "./template-gates.js";
|
|
13
15
|
const EMPTY_REGIONS = { header: "", signature: "", footer: "" };
|
|
@@ -31,8 +33,12 @@ function toSourceRow(row) {
|
|
|
31
33
|
function layoutFormat(row) {
|
|
32
34
|
return row.contentFormat === "html" || row.contentFormat === "mjml" ? row.contentFormat : null;
|
|
33
35
|
}
|
|
34
|
-
function pickEditedRows(rows,
|
|
35
|
-
|
|
36
|
+
function pickEditedRows(rows, caller, scope, locale) {
|
|
37
|
+
// A SystemAdmin editing the tenant layout sees the system version like a tenant does: served only, no draft.
|
|
38
|
+
const user = scope === "system"
|
|
39
|
+
? caller
|
|
40
|
+
: { ...caller, roles: caller.roles.filter((role) => role !== "SystemAdmin") };
|
|
41
|
+
const tenantRow = scope === "system"
|
|
36
42
|
? null
|
|
37
43
|
: pickByLocale(rows.filter((row) => row.scope === "tenant"), locale);
|
|
38
44
|
const systemRow = pickByLocale(rows.filter((row) => row.scope === "system" && !isHiddenSystemDraft(user, row)), locale);
|
|
@@ -73,15 +79,29 @@ export const layoutSourceQuery = defineQueryHandler({
|
|
|
73
79
|
kind: z.enum(["mail-layout", "pdf-layout"]),
|
|
74
80
|
slug: templateSlugSchema.default("default"),
|
|
75
81
|
locale: localeSchema.optional(),
|
|
82
|
+
scope: z.enum(["tenant", "system"]).optional(),
|
|
76
83
|
}),
|
|
77
84
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
78
|
-
description: "Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version and also receives its mjml source. Use it to load the layout editor.",
|
|
85
|
+
description: "Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version by default and also receives its mjml source; scope tenant loads the SystemAdmin's own tenant layout instead, and scope system is refused for everyone else. Use it to load the layout editor.",
|
|
79
86
|
handler: async (query, ctx) => {
|
|
80
87
|
const { kind, slug, locale } = query.payload;
|
|
81
|
-
const
|
|
88
|
+
const callerIsSystemAdmin = query.user.roles.includes("SystemAdmin");
|
|
89
|
+
const scope = query.payload.scope ?? (callerIsSystemAdmin ? "system" : "tenant");
|
|
90
|
+
if (scope === "system" && !callerIsSystemAdmin) {
|
|
91
|
+
throw new AccessDeniedError({
|
|
92
|
+
i18nKey: "errors.access.denied",
|
|
93
|
+
details: { reason: TemplateResolverErrors.systemAdminRequired },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
82
96
|
const policy = deliveryPolicyOf(ctx.registry);
|
|
83
97
|
const rows = await filterDeliveredTo(ctx.db, policy, query.user, await selectMany(ctx.db, templateResourcesTable, { kind, slug }));
|
|
84
|
-
const { tenantRow, systemRow } = pickEditedRows(rows, query.user, locale);
|
|
85
|
-
return buildLayoutSource({
|
|
98
|
+
const { tenantRow, systemRow } = pickEditedRows(rows, query.user, scope, locale);
|
|
99
|
+
return buildLayoutSource({
|
|
100
|
+
kind,
|
|
101
|
+
locale,
|
|
102
|
+
isSystemAdmin: scope === "system",
|
|
103
|
+
tenantRow,
|
|
104
|
+
systemRow,
|
|
105
|
+
});
|
|
86
106
|
},
|
|
87
107
|
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import { templateResourcesTable } from "../table.js";
|
|
5
6
|
import { deliveryPolicyOf, isTenantOverrideServed, } from "../template-delivery-policy.js";
|
|
6
7
|
import { activeSinceOf, activeVersionOf, draftViewerTenantId, isHiddenSystemDraft, isSystemAdmin, parseTemplateDraft, servedVersionOf, } from "../template-draft.js";
|
|
7
|
-
import { mayEditTemplate } from "../template-edit-policy.js";
|
|
8
|
+
import { mayEditTemplate, templateEditAccess, } from "../template-edit-policy.js";
|
|
8
9
|
import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
|
|
9
10
|
import { collectTemplateUsage } from "../template-usage.js";
|
|
10
11
|
import { DEFAULT_MAIL_LAYOUT_SLUG, FALLBACK_LOCALE, } from "../web/constants.js";
|
|
@@ -107,7 +108,9 @@ function splitDocumentSlug(slug) {
|
|
|
107
108
|
? { feature: "", notification: slug }
|
|
108
109
|
: { feature: slug.slice(0, separator), notification: slug.slice(separator + 1) };
|
|
109
110
|
}
|
|
110
|
-
function compareBy(field, a, b) {
|
|
111
|
+
function compareBy(field, a, b, shownTitle) {
|
|
112
|
+
if (field === "title")
|
|
113
|
+
return shownTitle(a).localeCompare(shownTitle(b));
|
|
111
114
|
return String(a[field]).localeCompare(String(b[field]));
|
|
112
115
|
}
|
|
113
116
|
function isSortField(value) {
|
|
@@ -207,13 +210,15 @@ function withOverriddenBy(rows, overrides) {
|
|
|
207
210
|
}
|
|
208
211
|
: row);
|
|
209
212
|
}
|
|
210
|
-
function filterRows(rows, search, filters, layoutSlug) {
|
|
213
|
+
function filterRows(rows, search, filters, layoutSlug, shownTitle) {
|
|
211
214
|
const needle = search?.trim().toLowerCase() ?? "";
|
|
212
215
|
return rows.filter((row) => {
|
|
213
216
|
if (layoutSlug !== undefined && row.layoutSlug !== layoutSlug)
|
|
214
217
|
return false;
|
|
215
|
-
if (needle !== "" &&
|
|
218
|
+
if (needle !== "" &&
|
|
219
|
+
!`${shownTitle(row)} ${row.title} ${row.slug}`.toLowerCase().includes(needle)) {
|
|
216
220
|
return false;
|
|
221
|
+
}
|
|
217
222
|
return filters.every((filter) => filter.value.includes(String(row[filter.field])));
|
|
218
223
|
});
|
|
219
224
|
}
|
|
@@ -235,7 +240,7 @@ export function makeOverviewQuery(policy) {
|
|
|
235
240
|
offset: z.number().int().min(0).optional(),
|
|
236
241
|
totalCount: z.boolean().optional(),
|
|
237
242
|
}),
|
|
238
|
-
access:
|
|
243
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
239
244
|
description: "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; templates of notifications registered with `systemOnly` are listed for SystemAdmins only, and a TenantAdmin sees only the templates the app lists in tenantEditable; a tenant override of a template that is not listed is not delivered, so the row shows the system version with `lockedOverride` true (SystemAdmin only); use it to browse and filter all templates at a glance.",
|
|
240
245
|
handler: async (query, ctx) => {
|
|
241
246
|
const payload = query.payload;
|
|
@@ -255,10 +260,13 @@ export function makeOverviewQuery(policy) {
|
|
|
255
260
|
const overviewRows = isSystemAdmin(query.user)
|
|
256
261
|
? withOverriddenBy(rowsWithUsage, tenantOverrideCountsResultSchema.parse(await ctx.query(TemplateResolverQueries.tenantOverrideCounts, {})))
|
|
257
262
|
: rowsWithUsage;
|
|
258
|
-
|
|
263
|
+
// A seeded title can be an i18n key; search and sort must see what the admin sees.
|
|
264
|
+
const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
|
|
265
|
+
const shownTitle = (row) => i18n.t(row.title, ctx.locale);
|
|
266
|
+
const filtered = filterRows(overviewRows, payload.search, payload.filters ?? [], payload.layoutSlug, shownTitle);
|
|
259
267
|
const sortField = payload.sort !== undefined && isSortField(payload.sort) ? payload.sort : "modifiedAt";
|
|
260
268
|
const direction = (payload.sortDirection ?? "desc") === "desc" ? -1 : 1;
|
|
261
|
-
filtered.sort((a, b) => direction * compareBy(sortField, a, b) || a.id.localeCompare(b.id));
|
|
269
|
+
filtered.sort((a, b) => direction * compareBy(sortField, a, b, shownTitle) || a.id.localeCompare(b.id));
|
|
262
270
|
const offset = payload.offset ?? 0;
|
|
263
271
|
return {
|
|
264
272
|
rows: filtered.slice(offset, offset + payload.limit),
|
|
@@ -27,7 +27,7 @@ export function makePatchDraftWrite(policy) {
|
|
|
27
27
|
ops: documentOpsSchema,
|
|
28
28
|
}),
|
|
29
29
|
access: templateEditAccess(policy.tenantEditable),
|
|
30
|
-
description: "Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
|
|
30
|
+
description: "Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once within one formatting run and cannot contain a {{placeholder}}: a find across a formatting change fails with text_spans_formatting, a find with a placeholder with placeholder_not_replaceable, and the formatting of the replaced text is kept), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
|
|
31
31
|
handler: async (event, ctx) => {
|
|
32
32
|
const { slug, kind, locale, base, ops } = event.payload;
|
|
33
33
|
const refused = refuseTemplateEdit(policy, event.user, { slug, kind });
|