@cosmicdrift/kumiko-samples 0.377.0 → 0.378.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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  4. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
  5. package/packages/bundled-features/src/config/web/i18n.ts +1 -0
  6. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  7. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
  8. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  9. package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
  10. package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
  11. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  12. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
  13. package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
  14. package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
  15. package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
  16. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
  17. package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
  18. package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
  19. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
  20. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
  21. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
  22. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
  23. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
  24. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
  25. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
  26. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
  27. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
  28. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
  29. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
  30. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
  31. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
  32. package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
  33. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
  34. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
  35. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
  36. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
  37. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
  38. package/packages/bundled-features/src/template-resolver/index.ts +8 -1
  39. package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
  40. package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
  41. package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
  42. package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
  43. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
  44. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
  45. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
  46. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  47. package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
  48. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  49. package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
  50. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
  51. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
  52. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
  53. package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
  54. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
  55. package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
  56. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
  57. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
  58. package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
  59. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  60. package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
  61. package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
  62. package/samples/recipes/delivery-notifications/README.md +1 -1
  63. package/samples/recipes/embedded/README.md +8 -0
  64. package/samples/recipes/embedded/src/feature.ts +8 -0
  65. package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
  66. package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
@@ -1,23 +1,23 @@
1
- import { defineWriteHandler, type Registry } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
- import {
5
- collectChannels,
6
- createEmailAttachmentReader,
7
- EMAIL_CHANNEL_NAME,
8
- } from "../../delivery/index.ts";
9
- import { fileContextForTenant } from "../../file-foundation/index.ts";
10
4
  import { TEST_MAIL_SUBJECT_PREFIX } from "../constants.ts";
11
5
  import { TemplateResolverErrors } from "../qualified-names.ts";
6
+ import {
7
+ refuseTemplateEdit,
8
+ type TemplateEditPolicy,
9
+ templateEditAccess,
10
+ } from "../template-edit-policy.ts";
12
11
  import { renderTemplatePreview } from "../template-preview.ts";
12
+ import {
13
+ emailChannelOf,
14
+ resolveTestMailAddress,
15
+ testMailChannelContext,
16
+ } from "./send-test-shared.ts";
13
17
  import { localeSchema, templateSlugSchema } from "./shared.ts";
14
18
 
15
19
  const TEST_MAIL_RATE_LIMIT = { limit: 10, windowSeconds: 3600 } as const;
16
20
 
17
- function emailChannelOf(registry: Registry) {
18
- return collectChannels(registry).find((channel) => channel.name === EMAIL_CHANNEL_NAME);
19
- }
20
-
21
21
  // Transport errors can carry provider responses or the address, so the client only gets a fixed code.
22
22
  function testMailFailed(code: "channel_error" | "not_sent") {
23
23
  return writeFailure(
@@ -34,105 +34,99 @@ function errorCodeOf(error: unknown): string | undefined {
34
34
  }
35
35
 
36
36
  // The only recipient is the caller's own address, resolved server-side, so the handler can never mail a third party.
37
- export const sendTestWrite = defineWriteHandler({
38
- name: "send-test",
39
- schema: z.object({
40
- slug: templateSlugSchema,
41
- locale: localeSchema,
42
- content: z.string().max(200_000).optional(),
43
- meta: z.unknown().optional(),
44
- variableSchema: z.record(z.string(), z.unknown()).default({}),
45
- variables: z.record(z.string(), z.unknown()).optional(),
46
- includeAttachments: z.boolean().default(true),
47
- prefixSubject: z.boolean().default(true),
48
- }),
49
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
50
- rateLimit: { per: "user+handler", ...TEST_MAIL_RATE_LIMIT },
51
- description:
52
- "Sends the rendered preview of a mail template draft, or of the stored template when no content is given, to the calling user's own address with the subject prefixed by [TEST] unless prefixSubject is false, carrying the inline images and, unless includeAttachments is false, the files attached to the template; there is no recipient input, the sample values come from the declared variable examples unless variables replaces them (checked against the variable schema), and a draft that fails the write rules is rejected without sending.",
53
- handler: async (event, ctx) => {
54
- const { includeAttachments, prefixSubject, ...previewPayload } = event.payload;
55
- const outcome = await renderTemplatePreview(ctx, event.user, {
56
- ...previewPayload,
57
- kind: "mail",
58
- });
59
- if (!outcome.ok) return writeFailure(outcome.error);
60
- const { preview, sender } = outcome;
61
- const mailAttachments = includeAttachments
62
- ? outcome.mailAttachments
63
- : outcome.mailAttachments.filter((ref) => ref.kind === "inline");
64
-
65
- const channel = emailChannelOf(ctx.registry);
66
- if (channel === undefined) {
67
- return writeFailure(
68
- new UnprocessableError(TemplateResolverErrors.testMailNoChannel, {
69
- i18nKey: "templateResolver.errors.testMail.noChannel",
70
- }),
71
- );
72
- }
73
- const channelContext = {
74
- db: ctx.db,
75
- registry: ctx.registry,
76
- sseBroker: undefined,
77
- tenantId: event.user.tenantId,
78
- secrets: ctx.secrets,
79
- config: ctx.config,
80
- readAttachment: createEmailAttachmentReader(
81
- ctx.db,
82
- ctx.registry,
83
- event.user.tenantId,
84
- ctx.config,
85
- (tenantId) => fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
86
- ),
87
- };
88
- const address = (await channel.resolve?.(event.user.id, channelContext)) ?? null;
89
- if (address === null) {
90
- return writeFailure(
91
- new UnprocessableError(TemplateResolverErrors.testMailNoAddress, {
92
- i18nKey: "templateResolver.errors.testMail.noAddress",
93
- }),
94
- );
95
- }
96
-
97
- const subject = `${prefixSubject ? TEST_MAIL_SUBJECT_PREFIX : ""}${preview.subject ?? ""}`;
98
- let sentStatus: string;
99
- try {
100
- const result = await channel.send(
101
- address,
102
- {
103
- // The template slug is the notification name, so the test mail takes the transport the real mail takes.
104
- notificationType: event.payload.slug,
105
- title: subject,
106
- body: undefined,
107
- data: undefined,
108
- locale: event.payload.locale,
109
- },
110
- channelContext,
111
- {
112
- subject,
113
- html: preview.html,
114
- text: preview.text ?? "",
115
- ...sender,
116
- attachments: mailAttachments,
117
- },
118
- );
119
- sentStatus = result.status;
120
- } catch (error) {
121
- // Class and code only: the message can carry the address or a provider response body.
122
- ctx.log?.warn("template test mail: channel threw", {
123
- tenantId: event.user.tenantId,
124
- errorClass: error instanceof Error ? error.name : typeof error,
125
- errorCode: errorCodeOf(error),
37
+ export function makeSendTestWrite(policy: TemplateEditPolicy) {
38
+ return defineWriteHandler({
39
+ name: "send-test",
40
+ schema: z.object({
41
+ slug: templateSlugSchema,
42
+ locale: localeSchema,
43
+ content: z.string().max(200_000).optional(),
44
+ meta: z.unknown().optional(),
45
+ variableSchema: z.record(z.string(), z.unknown()).default({}),
46
+ variables: z.record(z.string(), z.unknown()).optional(),
47
+ includeAttachments: z.boolean().default(true),
48
+ prefixSubject: z.boolean().default(true),
49
+ }),
50
+ access: templateEditAccess(policy.tenantEditable),
51
+ rateLimit: { per: "user+handler", ...TEST_MAIL_RATE_LIMIT },
52
+ description:
53
+ "Sends the rendered preview of a mail template draft, or of the stored template when no content is given, to the calling user's own address with the subject prefixed by [TEST] unless prefixSubject is false, carrying the inline images and, unless includeAttachments is false, the files attached to the template; there is no recipient input, the sample values come from the declared variable examples unless variables replaces them (checked against the variable schema), and a draft that fails the write rules is rejected without sending.",
54
+ handler: async (event, ctx) => {
55
+ // Sending a test is part of editing, so it follows the same edit policy as the editor writes.
56
+ const refused = refuseTemplateEdit(policy, event.user, {
57
+ slug: event.payload.slug,
58
+ kind: "mail",
126
59
  });
127
- return testMailFailed("channel_error");
128
- }
129
- if (sentStatus !== "sent") {
130
- ctx.log?.warn("template test mail: channel did not send", {
131
- tenantId: event.user.tenantId,
132
- status: sentStatus,
60
+ if (refused !== null) return refused;
61
+ const { includeAttachments, prefixSubject, ...previewPayload } = event.payload;
62
+ const outcome = await renderTemplatePreview(ctx, event.user, {
63
+ ...previewPayload,
64
+ kind: "mail",
133
65
  });
134
- return testMailFailed("not_sent");
135
- }
136
- return { isSuccess: true as const, data: { subject, checks: preview.checks } };
137
- },
138
- });
66
+ if (!outcome.ok) return writeFailure(outcome.error);
67
+ const { preview, sender } = outcome;
68
+ const mailAttachments = includeAttachments
69
+ ? outcome.mailAttachments
70
+ : outcome.mailAttachments.filter((ref) => ref.kind === "inline");
71
+
72
+ const channel = emailChannelOf(ctx.registry);
73
+ if (channel === undefined) {
74
+ return writeFailure(
75
+ new UnprocessableError(TemplateResolverErrors.testMailNoChannel, {
76
+ i18nKey: "templateResolver.errors.testMail.noChannel",
77
+ }),
78
+ );
79
+ }
80
+ const channelContext = testMailChannelContext(ctx, event.user);
81
+ const address = await resolveTestMailAddress(channel, channelContext, event.user);
82
+ if (address === null) {
83
+ return writeFailure(
84
+ new UnprocessableError(TemplateResolverErrors.testMailNoAddress, {
85
+ i18nKey: "templateResolver.errors.testMail.noAddress",
86
+ }),
87
+ );
88
+ }
89
+
90
+ const subject = `${prefixSubject ? TEST_MAIL_SUBJECT_PREFIX : ""}${preview.subject ?? ""}`;
91
+ let sentStatus: string;
92
+ try {
93
+ const result = await channel.send(
94
+ address,
95
+ {
96
+ // The template slug is the notification name, so the test mail takes the transport the real mail takes.
97
+ notificationType: event.payload.slug,
98
+ title: subject,
99
+ body: undefined,
100
+ data: undefined,
101
+ locale: event.payload.locale,
102
+ },
103
+ channelContext,
104
+ {
105
+ subject,
106
+ html: preview.html,
107
+ text: preview.text ?? "",
108
+ ...sender,
109
+ attachments: mailAttachments,
110
+ },
111
+ );
112
+ sentStatus = result.status;
113
+ } catch (error) {
114
+ // Class and code only: the message can carry the address or a provider response body.
115
+ ctx.log?.warn("template test mail: channel threw", {
116
+ tenantId: event.user.tenantId,
117
+ errorClass: error instanceof Error ? error.name : typeof error,
118
+ errorCode: errorCodeOf(error),
119
+ });
120
+ return testMailFailed("channel_error");
121
+ }
122
+ if (sentStatus !== "sent") {
123
+ ctx.log?.warn("template test mail: channel did not send", {
124
+ tenantId: event.user.tenantId,
125
+ status: sentStatus,
126
+ });
127
+ return testMailFailed("not_sent");
128
+ }
129
+ return { isSuccess: true as const, data: { subject, checks: preview.checks } };
130
+ },
131
+ });
132
+ }
@@ -9,6 +9,11 @@ import * as z from "zod";
9
9
  import { requireForTenant } from "../../shared/index.ts";
10
10
  import { TEMPLATE_KINDS, TEXT_BLOCK_KIND, UPSERT_KINDS } from "../constants.ts";
11
11
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
12
+ import {
13
+ refuseTemplateEdit,
14
+ type TemplateEditPolicy,
15
+ templateEditAccess,
16
+ } from "../template-edit-policy.ts";
12
17
  import { resolveTemplateVariables } from "../template-validation.ts";
13
18
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.ts";
14
19
  import { parseStoredJson, storedRecord } from "./template-gates.ts";
@@ -33,115 +38,117 @@ import { gateTemplateWrite } from "./template-write.ts";
33
38
  //
34
39
  // Kinds that upsertSystem/upsertTenant also write run the same validation
35
40
  // pipeline here, and because saving publishes, publish issues reject too.
36
- export const setWrite = defineWriteHandler({
37
- name: "set",
38
- schema: z.object({
39
- slug: slugSchema,
40
- locale: localeSchema,
41
- /** Which kind to author. Defaults to text-block. */
42
- kind: z.enum(TEMPLATE_KINDS).optional(),
43
- title: z.string().min(1).max(200),
44
- content: z.string().max(200_000).nullable(),
45
- contentFormat: contentFormatSchema.default("markdown"),
46
- /** Folder path for the content tree. Optional + null = root node. */
47
- folder: folderSchema.nullable().optional(),
48
- /** Optional cross-tenant write — SystemAdmin only. Typical use: the
49
- * editor lets a SystemAdmin write onto SYSTEM_TENANT_ID, otherwise the
50
- * text lands on their own platform tenant and the legal routes miss it. */
51
- tenantIdOverride: z.string().min(1).optional(),
52
- }),
53
- // SystemAdmin is a global role, TenantAdmin is per tenant-membership — both
54
- // paths are needed explicitly, otherwise nobody can set SYSTEM_TENANT texts.
55
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
56
- description:
57
- "Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives.",
58
- handler: async (event, ctx) => {
59
- const override = event.payload.tenantIdOverride;
60
- // @cast-boundary engine-payload — override is a zod-validated string, the
61
- // user's own tenantId is already TenantId-branded.
62
- const tenantId = (override ?? event.user.tenantId) as TenantId;
63
- const { db, streamTenantId } = requireForTenant(ctx, tenantId);
41
+ export function makeSetWrite(policy: TemplateEditPolicy) {
42
+ return defineWriteHandler({
43
+ name: "set",
44
+ schema: z.object({
45
+ slug: slugSchema,
46
+ locale: localeSchema,
47
+ /** Which kind to author. Defaults to text-block. */
48
+ kind: z.enum(TEMPLATE_KINDS).optional(),
49
+ title: z.string().min(1).max(200),
50
+ content: z.string().max(200_000).nullable(),
51
+ contentFormat: contentFormatSchema.default("markdown"),
52
+ /** Folder path for the content tree. Optional + null = root node. */
53
+ folder: folderSchema.nullable().optional(),
54
+ /** Optional cross-tenant write — SystemAdmin only. Typical use: the
55
+ * editor lets a SystemAdmin write onto SYSTEM_TENANT_ID, otherwise the
56
+ * text lands on their own platform tenant and the legal routes miss it. */
57
+ tenantIdOverride: z.string().min(1).optional(),
58
+ }),
59
+ access: templateEditAccess(policy.tenantEditable),
60
+ description:
61
+ "Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives. A TenantAdmin may write only the slug and kind pairs the app lists in tenantEditable, otherwise SystemAdmin only.",
62
+ handler: async (event, ctx) => {
63
+ const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
64
+ const refused = refuseTemplateEdit(policy, event.user, { slug: event.payload.slug, kind });
65
+ if (refused !== null) return refused;
66
+ const override = event.payload.tenantIdOverride;
67
+ // @cast-boundary engine-payload — override is a zod-validated string, the
68
+ // user's own tenantId is already TenantId-branded.
69
+ const tenantId = (override ?? event.user.tenantId) as TenantId;
70
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
64
71
 
65
- const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
66
- const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
67
- tenantId,
68
- slug: event.payload.slug,
69
- kind,
70
- locale: event.payload.locale,
71
- });
72
+ const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
73
+ tenantId,
74
+ slug: event.payload.slug,
75
+ kind,
76
+ locale: event.payload.locale,
77
+ });
72
78
 
73
- const scope = tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const);
74
- let content = event.payload.content;
75
- if (isOneOf(UPSERT_KINDS, kind)) {
76
- const gated = gateTemplateWrite(
77
- {
78
- kind,
79
- scope,
80
- contentFormat: event.payload.contentFormat,
81
- content,
82
- rawMeta: parseStoredJson(existing?.meta ?? null),
83
- variables: resolveTemplateVariables(
84
- ctx.registry,
85
- event.payload.slug,
86
- storedRecord(existing?.variableSchema ?? null),
87
- ),
88
- },
89
- "active",
90
- );
91
- if (!gated.ok) return gated.failure;
92
- content = gated.evaluated.content;
93
- }
79
+ const scope = tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const);
80
+ let content = event.payload.content;
81
+ if (isOneOf(UPSERT_KINDS, kind)) {
82
+ const gated = gateTemplateWrite(
83
+ {
84
+ kind,
85
+ scope,
86
+ contentFormat: event.payload.contentFormat,
87
+ content,
88
+ rawMeta: parseStoredJson(existing?.meta ?? null),
89
+ variables: resolveTemplateVariables(
90
+ ctx.registry,
91
+ event.payload.slug,
92
+ storedRecord(existing?.variableSchema ?? null),
93
+ ),
94
+ },
95
+ "active",
96
+ );
97
+ if (!gated.ok) return gated.failure;
98
+ content = gated.evaluated.content;
99
+ }
94
100
 
95
- const fields = {
96
- slug: event.payload.slug,
97
- kind,
98
- locale: event.payload.locale,
99
- title: event.payload.title,
100
- content,
101
- contentFormat: event.payload.contentFormat,
102
- folder: event.payload.folder ?? null,
103
- variableSchema: "{}",
104
- linkedResources: "{}",
105
- scope,
106
- parentTemplateId: null,
107
- // Text blocks have no draft stage — saving publishes, so resolveTemplate
108
- // (which only returns active rows) finds them.
109
- status: "active" as const,
110
- };
101
+ const fields = {
102
+ slug: event.payload.slug,
103
+ kind,
104
+ locale: event.payload.locale,
105
+ title: event.payload.title,
106
+ content,
107
+ contentFormat: event.payload.contentFormat,
108
+ folder: event.payload.folder ?? null,
109
+ variableSchema: "{}",
110
+ linkedResources: "{}",
111
+ scope,
112
+ parentTemplateId: null,
113
+ // Text blocks have no draft stage — saving publishes, so resolveTemplate
114
+ // (which only returns active rows) finds them.
115
+ status: "active" as const,
116
+ };
111
117
 
112
- if (existing) {
113
- const result = await executor.update(
114
- {
115
- id: existing.id,
116
- version: existing.version,
117
- // Only the editable columns — slug/kind/locale are the unique key,
118
- // and rewriting scope/status/variableSchema here would reset whatever
119
- // the template upserts put on the row.
120
- changes: {
121
- title: fields.title,
122
- content: fields.content,
123
- contentFormat: fields.contentFormat,
124
- folder: fields.folder,
118
+ if (existing) {
119
+ const result = await executor.update(
120
+ {
121
+ id: existing.id,
122
+ version: existing.version,
123
+ // Only the editable columns — slug/kind/locale are the unique key,
124
+ // and rewriting scope/status/variableSchema here would reset whatever
125
+ // the template upserts put on the row.
126
+ changes: {
127
+ title: fields.title,
128
+ content: fields.content,
129
+ contentFormat: fields.contentFormat,
130
+ folder: fields.folder,
131
+ },
125
132
  },
126
- },
127
- event.user,
128
- db,
129
- { streamTenantId },
130
- );
133
+ event.user,
134
+ db,
135
+ { streamTenantId },
136
+ );
137
+ if (!result.isSuccess) return result;
138
+ return {
139
+ isSuccess: true as const,
140
+ data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
141
+ };
142
+ }
143
+
144
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
145
+ streamTenantId,
146
+ });
131
147
  if (!result.isSuccess) return result;
132
148
  return {
133
149
  isSuccess: true as const,
134
- data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
150
+ data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
135
151
  };
136
- }
137
-
138
- const result = await executor.create({ ...fields, tenantId }, event.user, db, {
139
- streamTenantId,
140
- });
141
- if (!result.isSuccess) return result;
142
- return {
143
- isSuccess: true as const,
144
- data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
145
- };
146
- },
147
- });
152
+ },
153
+ });
154
+ }
@@ -15,6 +15,12 @@ import { requireForTenant } from "../../shared/index.ts";
15
15
  import type { TemplateResourceRow } from "../table.ts";
16
16
  import { templateResourcesTable } from "../table.ts";
17
17
  import { activationChanges, parseTemplateDraft, toWorkingRow } from "../template-draft.ts";
18
+ import {
19
+ refuseMissingTemplate,
20
+ refuseTemplateEdit,
21
+ type TemplateEditPolicy,
22
+ templateEditAccess,
23
+ } from "../template-edit-policy.ts";
18
24
  import { executor } from "./shared.ts";
19
25
  import { publishGateIssues } from "./template-gates.ts";
20
26
 
@@ -27,8 +33,10 @@ function isRowOfTarget(row: TemplateResourceRow, target: StatusTarget, callerTen
27
33
  : row.tenantId === callerTenantId && row.scope === "tenant";
28
34
  }
29
35
 
30
- export function writeAccessFor(target: StatusTarget) {
31
- return { roles: target === "system" ? ["SystemAdmin"] : ["TenantAdmin", "SystemAdmin"] };
36
+ export function writeAccessFor(target: StatusTarget, policy: TemplateEditPolicy) {
37
+ return target === "system"
38
+ ? { roles: ["SystemAdmin"] }
39
+ : templateEditAccess(policy.tenantEditable);
32
40
  }
33
41
 
34
42
  // The tenant db also reads system rows; the row's own tenant must match the
@@ -65,6 +73,7 @@ function planStatusChange(
65
73
  }
66
74
 
67
75
  function createStatusUpdateHandler(
76
+ policy: TemplateEditPolicy,
68
77
  name: string,
69
78
  target: StatusTarget,
70
79
  status: TemplateStatus,
@@ -73,13 +82,20 @@ function createStatusUpdateHandler(
73
82
  return defineWriteHandler({
74
83
  name,
75
84
  schema: z.object({ id: z.string().min(1) }),
76
- access: writeAccessFor(target),
85
+ access: writeAccessFor(target, policy),
77
86
  // A system template change reaches every tenant without an override.
78
87
  ...(target === "system" ? { agent: { risk: "high" as const } } : {}),
79
88
  description,
80
89
  handler: async (event, ctx) => {
81
90
  const { db, scoped, row } = await loadTargetRow(ctx, target, event.user, event.payload.id);
82
- if (!row) return writeFailure(new NotFoundError("template-resource", event.payload.id));
91
+ if (!row) {
92
+ const refused = target === "own" ? refuseMissingTemplate(policy, event.user) : null;
93
+ return refused ?? writeFailure(new NotFoundError("template-resource", event.payload.id));
94
+ }
95
+ if (target === "own") {
96
+ const refused = refuseTemplateEdit(policy, event.user, row);
97
+ if (refused !== null) return refused;
98
+ }
83
99
  const plan = planStatusChange(ctx, row, status);
84
100
  if (!plan.ok) return plan.failure;
85
101
  const result = await executor.update(
@@ -94,27 +110,39 @@ function createStatusUpdateHandler(
94
110
  });
95
111
  }
96
112
 
97
- export const archiveWrite = createStatusUpdateHandler(
98
- "archive",
99
- "own",
100
- "archived",
101
- "Sets one template resource of the caller's tenant to archived so the resolver stops serving it while the row and its content survive; use it to retire a template reversibly instead of deleting it. An open draft stays.",
102
- );
103
- export const publishWrite = createStatusUpdateHandler(
104
- "publish",
105
- "own",
106
- "active",
107
- "Sets one template resource of the caller's tenant to active so the resolver starts serving it; use it to release a draft written by upsert-tenant (a draft saved beside an active version replaces it), or to bring an archived template back; a version with unknown placeholders or unused required variables is refused and the served version stays.",
108
- );
109
- export const archiveSystemWrite = createStatusUpdateHandler(
110
- "archive-system",
111
- "system",
112
- "archived",
113
- "Sets one system-default template to archived so the resolver stops serving it to every tenant that has no override; SystemAdmin only, reversible with publish-system.",
114
- );
115
- export const publishSystemWrite = createStatusUpdateHandler(
116
- "publish-system",
117
- "system",
118
- "active",
119
- "Sets one system-default template to active so the resolver serves it again to every tenant that has no override; an open draft beside the active version replaces it; SystemAdmin only, a version with unknown placeholders or unused required variables is refused.",
120
- );
113
+ export function makeArchiveWrite(policy: TemplateEditPolicy) {
114
+ return createStatusUpdateHandler(
115
+ policy,
116
+ "archive",
117
+ "own",
118
+ "archived",
119
+ "Sets one template resource of the caller's tenant to archived so the resolver stops serving it while the row and its content survive; use it to retire a template reversibly instead of deleting it. An open draft stays. Outside the templates the app lists in tenantEditable only a SystemAdmin may use it.",
120
+ );
121
+ }
122
+ export function makePublishWrite(policy: TemplateEditPolicy) {
123
+ return createStatusUpdateHandler(
124
+ policy,
125
+ "publish",
126
+ "own",
127
+ "active",
128
+ "Sets one template resource of the caller's tenant to active so the resolver starts serving it; use it to release a draft written by upsert-tenant (a draft saved beside an active version replaces it), or to bring an archived template back; a version with unknown placeholders or unused required variables is refused and the served version stays. Outside the templates the app lists in tenantEditable only a SystemAdmin may use it.",
129
+ );
130
+ }
131
+ export function makeArchiveSystemWrite(policy: TemplateEditPolicy) {
132
+ return createStatusUpdateHandler(
133
+ policy,
134
+ "archive-system",
135
+ "system",
136
+ "archived",
137
+ "Sets one system-default template to archived so the resolver stops serving it to every tenant that has no override; SystemAdmin only, reversible with publish-system.",
138
+ );
139
+ }
140
+ export function makePublishSystemWrite(policy: TemplateEditPolicy) {
141
+ return createStatusUpdateHandler(
142
+ policy,
143
+ "publish-system",
144
+ "system",
145
+ "active",
146
+ "Sets one system-default template to active so the resolver serves it again to every tenant that has no override; an open draft beside the active version replaces it; SystemAdmin only, a version with unknown placeholders or unused required variables is refused.",
147
+ );
148
+ }