@anthusai/papyrus 5.43.0-next.49 → 5.43.0-next.50

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.
@@ -0,0 +1,68 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ /**
3
+ * Messages belong to their author, and the backend says so. The browser cannot create, edit or delete a Message through the
4
+ * model (editors and admins only read it); it calls these four mutations, whose resolvers stamp the author from the signed-in
5
+ * identity and check ownership against the stored `authorSub` in the same DynamoDB write. `renameThread` does the same for a
6
+ * conversation's title. Functions and IAM callers (the router, procedures, the console responder, email, Slack) write the
7
+ * model directly with their own grants, as before.
8
+ *
9
+ * Each resolver is one self-contained file (AppSync takes a single file and offers `@aws-appsync/utils` only).
10
+ */
11
+ const people = (allow) => [allow.group("editor"), allow.group("admin")];
12
+ const adminsOnly = (allow) => [allow.group("admin")];
13
+ const onMessages = (entry) => a.handler.custom({ dataSource: a.ref("Message"), entry });
14
+ const onThreads = (entry) => a.handler.custom({ dataSource: a.ref("MessageThread"), entry });
15
+ export const messageActionsSchemaDefinitions = {
16
+ postMessage: a
17
+ .mutation()
18
+ .arguments({
19
+ id: a.id().required(),
20
+ threadId: a.id().required(),
21
+ content: a.string(),
22
+ summary: a.string(),
23
+ parentMessageId: a.id(),
24
+ sequenceNumber: a.integer(),
25
+ messageKind: a.string().required(),
26
+ messageDomain: a.string().required(),
27
+ messageType: a.string(),
28
+ role: a.string(),
29
+ source: a.string(),
30
+ authorLabel: a.string(),
31
+ semanticLayer: a.string(),
32
+ searchVisibility: a.string(),
33
+ responseTarget: a.string(),
34
+ responseStatus: a.string(),
35
+ responseOwner: a.string(),
36
+ responseStartedAt: a.datetime(),
37
+ responseCompletedAt: a.datetime(),
38
+ metadata: a.json(),
39
+ newsroomFeedKey: a.string(),
40
+ })
41
+ .returns(a.ref("Message"))
42
+ .authorization(people)
43
+ .handler(onMessages("./resolvers/post-message.js")),
44
+ editMessage: a
45
+ .mutation()
46
+ .arguments({ id: a.id().required(), content: a.string().required(), summary: a.string() })
47
+ .returns(a.ref("Message"))
48
+ .authorization(people)
49
+ .handler(onMessages("./resolvers/edit-message.js")),
50
+ deleteOwnMessage: a
51
+ .mutation()
52
+ .arguments({ id: a.id().required() })
53
+ .returns(a.ref("Message"))
54
+ .authorization(people)
55
+ .handler(onMessages("./resolvers/delete-own-message.js")),
56
+ moderateMessage: a
57
+ .mutation()
58
+ .arguments({ id: a.id().required() })
59
+ .returns(a.ref("Message"))
60
+ .authorization(adminsOnly)
61
+ .handler(onMessages("./resolvers/moderate-message.js")),
62
+ renameThread: a
63
+ .mutation()
64
+ .arguments({ id: a.id().required(), title: a.string().required() })
65
+ .returns(a.ref("MessageThread"))
66
+ .authorization(people)
67
+ .handler(onThreads("./resolvers/rename-thread.js")),
68
+ };
@@ -0,0 +1,70 @@
1
+ import { a } from "@aws-amplify/backend";
2
+
3
+ /**
4
+ * Messages belong to their author, and the backend says so. The browser cannot create, edit or delete a Message through the
5
+ * model (editors and admins only read it); it calls these four mutations, whose resolvers stamp the author from the signed-in
6
+ * identity and check ownership against the stored `authorSub` in the same DynamoDB write. `renameThread` does the same for a
7
+ * conversation's title. Functions and IAM callers (the router, procedures, the console responder, email, Slack) write the
8
+ * model directly with their own grants, as before.
9
+ *
10
+ * Each resolver is one self-contained file (AppSync takes a single file and offers `@aws-appsync/utils` only).
11
+ */
12
+ const people = (allow: any) => [allow.group("editor"), allow.group("admin")];
13
+ const adminsOnly = (allow: any) => [allow.group("admin")];
14
+ const onMessages = (entry: string) => a.handler.custom({ dataSource: a.ref("Message"), entry });
15
+ const onThreads = (entry: string) => a.handler.custom({ dataSource: a.ref("MessageThread"), entry });
16
+
17
+ export const messageActionsSchemaDefinitions = {
18
+ postMessage: a
19
+ .mutation()
20
+ .arguments({
21
+ id: a.id().required(),
22
+ threadId: a.id().required(),
23
+ content: a.string(),
24
+ summary: a.string(),
25
+ parentMessageId: a.id(),
26
+ sequenceNumber: a.integer(),
27
+ messageKind: a.string().required(),
28
+ messageDomain: a.string().required(),
29
+ messageType: a.string(),
30
+ role: a.string(),
31
+ source: a.string(),
32
+ authorLabel: a.string(),
33
+ semanticLayer: a.string(),
34
+ searchVisibility: a.string(),
35
+ responseTarget: a.string(),
36
+ responseStatus: a.string(),
37
+ responseOwner: a.string(),
38
+ responseStartedAt: a.datetime(),
39
+ responseCompletedAt: a.datetime(),
40
+ metadata: a.json(),
41
+ newsroomFeedKey: a.string(),
42
+ })
43
+ .returns(a.ref("Message"))
44
+ .authorization(people)
45
+ .handler(onMessages("./resolvers/post-message.js")),
46
+ editMessage: a
47
+ .mutation()
48
+ .arguments({ id: a.id().required(), content: a.string().required(), summary: a.string() })
49
+ .returns(a.ref("Message"))
50
+ .authorization(people)
51
+ .handler(onMessages("./resolvers/edit-message.js")),
52
+ deleteOwnMessage: a
53
+ .mutation()
54
+ .arguments({ id: a.id().required() })
55
+ .returns(a.ref("Message"))
56
+ .authorization(people)
57
+ .handler(onMessages("./resolvers/delete-own-message.js")),
58
+ moderateMessage: a
59
+ .mutation()
60
+ .arguments({ id: a.id().required() })
61
+ .returns(a.ref("Message"))
62
+ .authorization(adminsOnly)
63
+ .handler(onMessages("./resolvers/moderate-message.js")),
64
+ renameThread: a
65
+ .mutation()
66
+ .arguments({ id: a.id().required(), title: a.string().required() })
67
+ .returns(a.ref("MessageThread"))
68
+ .authorization(people)
69
+ .handler(onThreads("./resolvers/rename-thread.js")),
70
+ };
@@ -0,0 +1,30 @@
1
+ // deleteOwnMessage: the author deletes their own Message. A soft delete: the Message stays (replies keep their place) marked
2
+ // deleted, with when and by whom. Nothing is removed.
3
+ import { util } from "@aws-appsync/utils";
4
+ import * as ddb from "@aws-appsync/utils/dynamodb";
5
+
6
+ export function request(ctx) {
7
+ const identity = ctx.identity;
8
+ if (!identity || !identity.sub) util.error("Sign in to delete a message.", "Unauthorized");
9
+ const now = util.time.nowISO8601();
10
+ return ddb.update({
11
+ key: { id: ctx.args.id },
12
+ update: {
13
+ status: ddb.operations.replace("deleted"),
14
+ deletedAt: ddb.operations.replace(now),
15
+ deletedBySub: ddb.operations.replace(identity.sub),
16
+ updatedAt: ddb.operations.replace(now),
17
+ },
18
+ condition: { authorSub: { eq: identity.sub }, status: { ne: "deleted" } },
19
+ });
20
+ }
21
+
22
+ export function response(ctx) {
23
+ if (ctx.error) {
24
+ if (ctx.error.type === "DynamoDB:ConditionalCheckFailedException") {
25
+ util.error("Only the author can delete their own message, and it may already be deleted.", "Unauthorized");
26
+ }
27
+ util.error(ctx.error.message, ctx.error.type);
28
+ }
29
+ return ctx.result;
30
+ }
@@ -0,0 +1,33 @@
1
+ // editMessage: a person changes the text of their own Message, and no one else's. The condition is checked by DynamoDB against the
2
+ // stored authorSub in the same write, so there is no window between the check and the change. A deleted Message cannot be edited.
3
+ // Authorship cannot be reassigned: the update never touches authorSub.
4
+ import { util } from "@aws-appsync/utils";
5
+ import * as ddb from "@aws-appsync/utils/dynamodb";
6
+
7
+ export function request(ctx) {
8
+ const identity = ctx.identity;
9
+ if (!identity || !identity.sub) util.error("Sign in to edit a message.", "Unauthorized");
10
+ const content = typeof ctx.args.content === "string" ? ctx.args.content.trim() : "";
11
+ if (!content) util.error("A message cannot be empty.", "BadRequest");
12
+ const now = util.time.nowISO8601();
13
+ return ddb.update({
14
+ key: { id: ctx.args.id },
15
+ update: {
16
+ content: ddb.operations.replace(content),
17
+ summary: ddb.operations.replace(ctx.args.summary ? ctx.args.summary : content.slice(0, 120)),
18
+ editedAt: ddb.operations.replace(now),
19
+ updatedAt: ddb.operations.replace(now),
20
+ },
21
+ condition: { authorSub: { eq: identity.sub }, status: { ne: "deleted" } },
22
+ });
23
+ }
24
+
25
+ export function response(ctx) {
26
+ if (ctx.error) {
27
+ if (ctx.error.type === "DynamoDB:ConditionalCheckFailedException") {
28
+ util.error("Only the author can edit a message, and a deleted message cannot be edited.", "Unauthorized");
29
+ }
30
+ util.error(ctx.error.message, ctx.error.type);
31
+ }
32
+ return ctx.result;
33
+ }
@@ -0,0 +1,32 @@
1
+ // moderateMessage: an admin removes anyone's Message. The same soft delete as the author's, with no change to the text, and only
2
+ // for the admin group (checked here as well as by the mutation's authorization).
3
+ import { util } from "@aws-appsync/utils";
4
+ import * as ddb from "@aws-appsync/utils/dynamodb";
5
+
6
+ export function request(ctx) {
7
+ const identity = ctx.identity;
8
+ if (!identity || !identity.sub) util.error("Sign in to moderate a message.", "Unauthorized");
9
+ const groups = identity.groups || [];
10
+ if (groups.indexOf("admin") < 0) util.error("Only an admin can delete someone else's message.", "Unauthorized");
11
+ const now = util.time.nowISO8601();
12
+ return ddb.update({
13
+ key: { id: ctx.args.id },
14
+ update: {
15
+ status: ddb.operations.replace("deleted"),
16
+ deletedAt: ddb.operations.replace(now),
17
+ deletedBySub: ddb.operations.replace(identity.sub),
18
+ updatedAt: ddb.operations.replace(now),
19
+ },
20
+ condition: { id: { attributeExists: true }, status: { ne: "deleted" } },
21
+ });
22
+ }
23
+
24
+ export function response(ctx) {
25
+ if (ctx.error) {
26
+ if (ctx.error.type === "DynamoDB:ConditionalCheckFailedException") {
27
+ util.error("That message does not exist or is already deleted.", "NotFound");
28
+ }
29
+ util.error(ctx.error.message, ctx.error.type);
30
+ }
31
+ return ctx.result;
32
+ }
@@ -0,0 +1,68 @@
1
+ // postMessage: the only way a person's Message is created. The author is the signed-in user (the identity's sub), whatever the
2
+ // client sends: the mutation has no author argument, and a label is only a label. One DynamoDB PutItem that refuses to replace an
3
+ // existing Message (ids are derived from the sequence number, so a lost race comes back as a conflict the caller retries).
4
+ import { util } from "@aws-appsync/utils";
5
+ import * as ddb from "@aws-appsync/utils/dynamodb";
6
+
7
+ function clean(value) {
8
+ const out = {};
9
+ const keys = Object.keys(value);
10
+ for (let index = 0; index < keys.length; index++) {
11
+ const key = keys[index];
12
+ if (value[key] !== null && value[key] !== undefined) out[key] = value[key];
13
+ }
14
+ return out;
15
+ }
16
+
17
+ export function request(ctx) {
18
+ const identity = ctx.identity;
19
+ if (!identity || !identity.sub) util.error("Sign in to post a message.", "Unauthorized");
20
+ const args = ctx.args;
21
+ const content = typeof args.content === "string" ? args.content.trim() : "";
22
+ // A placeholder waiting for a responder (PENDING) may start empty; any other message needs text.
23
+ if (!content && args.responseStatus !== "PENDING") util.error("A message cannot be empty.", "BadRequest");
24
+ if (!args.id || !args.threadId) util.error("A message needs an id and a conversation.", "BadRequest");
25
+ const now = util.time.nowISO8601();
26
+ const claims = identity.claims || {};
27
+ const email = typeof claims.email === "string" ? claims.email : "";
28
+ const label = typeof args.authorLabel === "string" && args.authorLabel.trim() ? args.authorLabel.trim() : email ? email : "editor";
29
+ const item = clean({
30
+ __typename: "Message",
31
+ id: args.id,
32
+ messageKind: args.messageKind,
33
+ messageDomain: args.messageDomain,
34
+ status: "active",
35
+ summary: args.summary ? args.summary : content.slice(0, 120),
36
+ source: args.source,
37
+ authorSub: identity.sub,
38
+ authorLabel: label,
39
+ threadId: args.threadId,
40
+ parentMessageId: args.parentMessageId,
41
+ sequenceNumber: args.sequenceNumber,
42
+ role: args.role ? args.role : "human",
43
+ messageType: args.messageType,
44
+ content: content,
45
+ semanticLayer: args.semanticLayer,
46
+ searchVisibility: args.searchVisibility,
47
+ responseTarget: args.responseTarget,
48
+ responseStatus: args.responseStatus ? args.responseStatus : "COMPLETED",
49
+ responseOwner: args.responseOwner,
50
+ responseStartedAt: args.responseStartedAt,
51
+ responseCompletedAt: args.responseCompletedAt,
52
+ metadata: args.metadata,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ newsroomFeedKey: args.newsroomFeedKey,
56
+ });
57
+ return ddb.put({ key: { id: args.id }, item: item, condition: { id: { attributeExists: false } } });
58
+ }
59
+
60
+ export function response(ctx) {
61
+ if (ctx.error) {
62
+ if (ctx.error.type === "DynamoDB:ConditionalCheckFailedException") {
63
+ util.error("A message with this id already exists (conditional check failed); try again.", "Conflict");
64
+ }
65
+ util.error(ctx.error.message, ctx.error.type);
66
+ }
67
+ return ctx.result;
68
+ }
@@ -0,0 +1,28 @@
1
+ // renameThread: the person who started a conversation, or an admin, gives it a new title. The browser's own update of a thread
2
+ // cannot change the title (the field is read and create only), so this is the one way. Nothing else about the thread changes.
3
+ import { util } from "@aws-appsync/utils";
4
+ import * as ddb from "@aws-appsync/utils/dynamodb";
5
+
6
+ export function request(ctx) {
7
+ const identity = ctx.identity;
8
+ if (!identity || !identity.sub) util.error("Sign in to rename a conversation.", "Unauthorized");
9
+ const title = typeof ctx.args.title === "string" ? ctx.args.title.trim() : "";
10
+ if (!title) util.error("A title cannot be empty.", "BadRequest");
11
+ const groups = identity.groups || [];
12
+ const isAdmin = groups.indexOf("admin") >= 0;
13
+ return ddb.update({
14
+ key: { id: ctx.args.id },
15
+ update: { title: ddb.operations.replace(title.slice(0, 120)) },
16
+ condition: isAdmin ? { id: { attributeExists: true } } : { createdBySub: { eq: identity.sub } },
17
+ });
18
+ }
19
+
20
+ export function response(ctx) {
21
+ if (ctx.error) {
22
+ if (ctx.error.type === "DynamoDB:ConditionalCheckFailedException") {
23
+ util.error("Only the person who started this conversation, or an admin, can rename it.", "Unauthorized");
24
+ }
25
+ util.error(ctx.error.message, ctx.error.type);
26
+ }
27
+ return ctx.result;
28
+ }
@@ -1726,12 +1726,11 @@ const schema = a.schema({
1726
1726
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1727
1727
  ])
1728
1728
  .authorization((allow) => [
1729
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1730
- allow.group("editor").to(categoryAppendOnlyOperations),
1731
- allow.group(adminGroup).to(["read", "create", "delete"]),
1729
+ // Messages belong to their author, and the backend stamps the author: people read Messages here and write them only through
1730
+ // postMessage, editMessage, deleteOwnMessage and moderateMessage (message-actions.ts). Functions and IAM callers write directly.
1731
+ allow.group("editor").to(["read"]),
1732
+ allow.group(adminGroup).to(["read"]),
1732
1733
  allow.authenticated("identityPool").to(["read", "create", "update"]),
1733
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1734
- allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1735
1734
  ]),
1736
1735
  MessageThread: a
1737
1736
  .model({
@@ -1842,12 +1842,11 @@ const schema = a.schema({
1842
1842
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1843
1843
  ])
1844
1844
  .authorization((allow) => [
1845
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1846
- allow.group("editor").to(categoryAppendOnlyOperations),
1847
- allow.group(adminGroup).to(["read", "create", "delete"]),
1845
+ // Messages belong to their author, and the backend stamps the author: people read Messages here and write them only through
1846
+ // postMessage, editMessage, deleteOwnMessage and moderateMessage (message-actions.ts). Functions and IAM callers write directly.
1847
+ allow.group("editor").to(["read"]),
1848
+ allow.group(adminGroup).to(["read"]),
1848
1849
  allow.authenticated("identityPool").to(["read", "create", "update"]),
1849
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1850
- allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1851
1850
  ]),
1852
1851
 
1853
1852
  MessageThread: a
@@ -4,6 +4,7 @@ import { categoryAction } from "../functions/category-action/resource.js";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema.js";
5
5
  import { automationViewSchemaDefinitions } from "../functions/automation-view/schema.js";
6
6
  import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema.js";
7
+ import { messageActionsSchemaDefinitions } from "./message-actions.js";
7
8
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema.js";
8
9
  import { contentActions } from "../functions/content-actions/resource.js";
9
10
  import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
@@ -1629,23 +1630,24 @@ function buildBaseDefinitions(includeEditions) {
1629
1630
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1630
1631
  ])
1631
1632
  .authorization((allow) => [
1632
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1633
- allow.group("editor").to(categoryAppendOnlyOperations),
1634
- allow.group(adminGroup).to(["read", "create", "delete"]),
1635
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1636
- allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1633
+ // Messages belong to their author, and the backend stamps the author: people read Messages here and write them only through
1634
+ // postMessage, editMessage, deleteOwnMessage and moderateMessage (message-actions.ts). Functions and IAM callers write directly.
1635
+ allow.group("editor").to(["read"]),
1636
+ allow.group(adminGroup).to(["read"]),
1637
1637
  ]),
1638
1638
  MessageThread: realtimeModel({
1639
1639
  id: a.id().required(),
1640
1640
  threadKind: a.string().required(),
1641
1641
  status: a.string().required(),
1642
- title: a.string().required(),
1642
+ // The title and the creator are set when a conversation is created and read afterwards; a title changes only through
1643
+ // renameThread (its creator or an admin), and the creator never changes.
1644
+ title: a.string().required().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1643
1645
  summary: a.string(),
1644
1646
  primaryAnchorKind: a.string(),
1645
1647
  primaryAnchorId: a.id(),
1646
1648
  primaryAnchorLineageId: a.id(),
1647
1649
  primaryAnchorKey: a.string(),
1648
- createdBySub: a.string(),
1650
+ createdBySub: a.string().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1649
1651
  createdByUserProfileId: a.id(),
1650
1652
  createdByLabel: a.string(),
1651
1653
  messageCount: a.integer(),
@@ -2130,6 +2132,7 @@ function buildBaseDefinitions(includeEditions) {
2130
2132
  ...monitoringViewSchemaDefinitions,
2131
2133
  ...automationViewSchemaDefinitions,
2132
2134
  ...proposalActionsSchemaDefinitions,
2135
+ ...messageActionsSchemaDefinitions,
2133
2136
  };
2134
2137
  if (!includeEditions) {
2135
2138
  const mutableDefinitions = definitions;
@@ -4,6 +4,7 @@ import { categoryAction } from "../functions/category-action/resource";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
5
5
  import { automationViewSchemaDefinitions } from "../functions/automation-view/schema";
6
6
  import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema";
7
+ import { messageActionsSchemaDefinitions } from "./message-actions";
7
8
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
8
9
  import { contentActions } from "../functions/content-actions/resource";
9
10
  import { knowledgeQuery } from "../functions/knowledge-query/resource";
@@ -1743,24 +1744,25 @@ const definitions = {
1743
1744
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1744
1745
  ])
1745
1746
  .authorization((allow) => [
1746
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1747
- allow.group("editor").to(categoryAppendOnlyOperations),
1748
- allow.group(adminGroup).to(["read", "create", "delete"]),
1749
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1750
- allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1747
+ // Messages belong to their author, and the backend stamps the author: people read Messages here and write them only through
1748
+ // postMessage, editMessage, deleteOwnMessage and moderateMessage (message-actions.ts). Functions and IAM callers write directly.
1749
+ allow.group("editor").to(["read"]),
1750
+ allow.group(adminGroup).to(["read"]),
1751
1751
  ]),
1752
1752
 
1753
1753
  MessageThread: realtimeModel({
1754
1754
  id: a.id().required(),
1755
1755
  threadKind: a.string().required(),
1756
1756
  status: a.string().required(),
1757
- title: a.string().required(),
1757
+ // The title and the creator are set when a conversation is created and read afterwards; a title changes only through
1758
+ // renameThread (its creator or an admin), and the creator never changes.
1759
+ title: a.string().required().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1758
1760
  summary: a.string(),
1759
1761
  primaryAnchorKind: a.string(),
1760
1762
  primaryAnchorId: a.id(),
1761
1763
  primaryAnchorLineageId: a.id(),
1762
1764
  primaryAnchorKey: a.string(),
1763
- createdBySub: a.string(),
1765
+ createdBySub: a.string().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1764
1766
  createdByUserProfileId: a.id(),
1765
1767
  createdByLabel: a.string(),
1766
1768
  messageCount: a.integer(),
@@ -2260,6 +2262,7 @@ const definitions = {
2260
2262
  ...monitoringViewSchemaDefinitions,
2261
2263
  ...automationViewSchemaDefinitions,
2262
2264
  ...proposalActionsSchemaDefinitions,
2265
+ ...messageActionsSchemaDefinitions,
2263
2266
  };
2264
2267
  if (!includeEditions) {
2265
2268
  const mutableDefinitions = definitions as Record<string, unknown>;
@@ -60,6 +60,7 @@ import {
60
60
  type ConversationAnchor,
61
61
  type ConversationMessage,
62
62
  } from "../lib/conversation";
63
+ import { postMessageArguments, postMessageMutation } from "../lib/post-message";
63
64
  import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
64
65
  import { getContentItemStageDefinitions } from "../lib/site-brand";
65
66
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
@@ -220,40 +221,7 @@ const UPDATE_MESSAGE_THREAD_MUTATION = `
220
221
  }
221
222
  `;
222
223
 
223
- const CREATE_MESSAGE_MUTATION = `
224
- mutation CreateMessage($input: CreateMessageInput!) {
225
- createMessage(input: $input) {
226
- id
227
- messageKind
228
- messageDomain
229
- status
230
- summary
231
- source
232
- importRunId
233
- authorSub
234
- authorUserProfileId
235
- authorLabel
236
- threadId
237
- parentMessageId
238
- sequenceNumber
239
- role
240
- messageType
241
- content
242
- semanticLayer
243
- searchVisibility
244
- responseTarget
245
- responseStatus
246
- responseOwner
247
- responseStartedAt
248
- responseCompletedAt
249
- responseError
250
- metadata
251
- createdAt
252
- updatedAt
253
- newsroomFeedKey
254
- }
255
- }
256
- `;
224
+ const POST_MESSAGE_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
257
225
 
258
226
  const UPDATE_MESSAGE_MUTATION = `
259
227
  mutation UpdateMessage($input: UpdateMessageInput!) {
@@ -1434,10 +1402,12 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
1434
1402
  // `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
1435
1403
  // sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
1436
1404
  built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
1437
- return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
1405
+ // The backend stamps the author from the signed-in identity (postMessage); nothing here names one.
1406
+ const args = postMessageArguments(built as unknown as Record<string, unknown>);
1407
+ return runGraphql<{ postMessage?: MessageRecord | null }>(postMessageMutation(args, POST_MESSAGE_SELECTION), args);
1438
1408
  },
1439
1409
  });
1440
- const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
1410
+ const createdMessage = normalizeMessageRecord(created.postMessage ?? built!);
1441
1411
  const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1442
1412
  UPDATE_MESSAGE_THREAD_MUTATION,
1443
1413
  {
@@ -1608,7 +1578,6 @@ export async function appendConversationMessage(input: {
1608
1578
  status: "active",
1609
1579
  summary: content.slice(0, 120),
1610
1580
  source: "newsroom.conversation",
1611
- authorSub: author.sub,
1612
1581
  authorLabel: author.label,
1613
1582
  threadId: thread.id,
1614
1583
  parentMessageId: input.parentMessageId?.trim() || null,
@@ -3,6 +3,7 @@
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import type { Schema } from "../amplify/data/resource";
5
5
  import { configureAmplifyClient } from "../components/amplify-client-provider";
6
+ import { postMessageArguments, postMessageMutation } from "./post-message";
6
7
  import { loadModelPayloadsForOwner, uploadModelPayloadForOwner } from "../components/news-desk-taxonomy-client";
7
8
 
8
9
  const USER_POOL_AUTH_MODE = "userPool";
@@ -190,14 +191,6 @@ const UPDATE_CONSOLE_THREAD = `
190
191
  }
191
192
  `;
192
193
 
193
- const CREATE_CONSOLE_MESSAGE_LEGACY = `
194
- mutation CreateMessage($input: CreateMessageInput!) {
195
- createMessage(input: $input) {
196
- ${CONSOLE_MESSAGE_FIELDS_LEGACY}
197
- }
198
- }
199
- `;
200
-
201
194
  export type ConsoleChatThread = {
202
195
  id: string;
203
196
  threadKind: string;
@@ -383,79 +376,6 @@ function chooseMessageSelectionSet(schemaFields: Set<string> | null): string {
383
376
  return selected.join("\n ");
384
377
  }
385
378
 
386
- function fieldValuesForCreateMessageInput(input: CreateConsoleMessageInput): Record<string, unknown> {
387
- return {
388
- id: input.id,
389
- messageKind: input.messageKind,
390
- messageDomain: input.messageDomain,
391
- status: input.status,
392
- summary: input.summary ?? truncateSummary(input.content ?? null),
393
- source: input.source,
394
- importRunId: null,
395
- authorSub: null,
396
- authorUserProfileId: input.authorUserProfileId ?? null,
397
- authorLabel: input.authorLabel,
398
- threadId: input.threadId ?? null,
399
- parentMessageId: input.parentMessageId ?? null,
400
- sequenceNumber: input.sequenceNumber ?? null,
401
- role: input.role ?? null,
402
- messageType: input.messageType ?? null,
403
- semanticLayer: input.semanticLayer,
404
- searchVisibility: input.searchVisibility,
405
- responseTarget: input.responseTarget,
406
- responseStatus: input.responseStatus ?? null,
407
- responseOwner: input.responseOwner ?? null,
408
- responseStartedAt: input.responseStartedAt ?? null,
409
- responseCompletedAt: input.responseCompletedAt ?? null,
410
- responseError: input.responseError ?? null,
411
- metadata: input.metadata,
412
- createdAt: input.createdAt,
413
- updatedAt: input.updatedAt,
414
- newsroomFeedKey: input.newsroomFeedKey,
415
- };
416
- }
417
-
418
- function buildCreateMessageInputCandidates(
419
- input: CreateConsoleMessageInput,
420
- availableFields: Set<string>,
421
- ): Record<string, unknown>[] {
422
- const values = fieldValuesForCreateMessageInput(input);
423
- const candidates: Record<string, unknown>[] = [];
424
- const seen = new Set<string>();
425
-
426
- const pushCandidate = (candidate: Record<string, unknown>) => {
427
- const cleaned: Record<string, unknown> = {};
428
- for (const [key, value] of Object.entries(candidate)) {
429
- if (value !== undefined) cleaned[key] = value;
430
- }
431
- const signature = JSON.stringify(Object.keys(cleaned).sort().map((key) => [key, cleaned[key]]));
432
- if (!seen.has(signature)) {
433
- seen.add(signature);
434
- candidates.push(cleaned);
435
- }
436
- };
437
-
438
- if (availableFields.size) {
439
- pushCandidate(sanitizeCreateMessageInput(input, availableFields));
440
- } else {
441
- pushCandidate(values);
442
- pushCandidate({
443
- id: values.id,
444
- messageKind: values.messageKind,
445
- messageDomain: values.messageDomain,
446
- status: values.status,
447
- summary: values.summary,
448
- source: values.source,
449
- authorLabel: values.authorLabel,
450
- createdAt: values.createdAt,
451
- updatedAt: values.updatedAt,
452
- newsroomFeedKey: values.newsroomFeedKey,
453
- });
454
- }
455
-
456
- return candidates;
457
- }
458
-
459
379
  async function loadConsoleSchemaSnapshot(): Promise<ConsoleSchemaSnapshot> {
460
380
  if (consoleSchemaSnapshot) return consoleSchemaSnapshot;
461
381
  if (consoleSchemaSnapshotPromise) return consoleSchemaSnapshotPromise;
@@ -540,70 +460,6 @@ function buildListMessagesQuery(
540
460
  `;
541
461
  }
542
462
 
543
- function sanitizeCreateMessageInput(
544
- input: CreateConsoleMessageInput,
545
- availableFields: Set<string>,
546
- ): Record<string, unknown> {
547
- const fieldValues: Record<string, unknown> = {
548
- id: input.id,
549
- messageKind: input.messageKind,
550
- messageDomain: input.messageDomain,
551
- status: input.status,
552
- summary: input.summary ?? truncateSummary(input.content ?? null),
553
- source: input.source,
554
- importRunId: null,
555
- authorSub: null,
556
- authorUserProfileId: input.authorUserProfileId ?? null,
557
- authorLabel: input.authorLabel,
558
- threadId: input.threadId ?? null,
559
- parentMessageId: input.parentMessageId ?? null,
560
- sequenceNumber: input.sequenceNumber ?? null,
561
- role: input.role ?? null,
562
- messageType: input.messageType ?? null,
563
- semanticLayer: input.semanticLayer,
564
- searchVisibility: input.searchVisibility,
565
- responseTarget: input.responseTarget,
566
- responseStatus: input.responseStatus ?? null,
567
- responseOwner: input.responseOwner ?? null,
568
- responseStartedAt: input.responseStartedAt ?? null,
569
- responseCompletedAt: input.responseCompletedAt ?? null,
570
- responseError: input.responseError ?? null,
571
- metadata: input.metadata,
572
- createdAt: input.createdAt,
573
- updatedAt: input.updatedAt,
574
- newsroomFeedKey: input.newsroomFeedKey,
575
- };
576
-
577
- if (!availableFields.size) {
578
- const conservativeFields = [
579
- "id",
580
- "messageKind",
581
- "messageDomain",
582
- "status",
583
- "summary",
584
- "source",
585
- "authorLabel",
586
- "createdAt",
587
- "updatedAt",
588
- "newsroomFeedKey",
589
- ] as const;
590
- const conservative: Record<string, unknown> = {};
591
- for (const key of conservativeFields) {
592
- const value = fieldValues[key];
593
- if (value !== undefined) conservative[key] = value;
594
- }
595
- return conservative;
596
- }
597
-
598
- const sanitized: Record<string, unknown> = {};
599
- for (const [key, value] of Object.entries(fieldValues)) {
600
- if (!availableFields.has(key)) continue;
601
- if (value === undefined) continue;
602
- sanitized[key] = value;
603
- }
604
- return sanitized;
605
- }
606
-
607
463
  function truncateSummary(value: string | null): string | null {
608
464
  if (!value) return null;
609
465
  const normalized = value.replace(/\s+/g, " ").trim();
@@ -733,53 +589,27 @@ export async function createConsoleMessage(input: CreateConsoleMessageInput): Pr
733
589
  const selectionSets = snapshot.messageFields.size
734
590
  ? [chooseMessageSelectionSet(snapshot.messageFields)]
735
591
  : [CONSOLE_MESSAGE_FIELDS, CONSOLE_MESSAGE_FIELDS_LEGACY];
736
- const inputCandidates = buildCreateMessageInputCandidates(input, snapshot.createMessageInputFields);
592
+ // The backend stamps the author from the signed-in identity (postMessage): the browser names none and sets no timestamps.
593
+ const args = postMessageArguments({ ...input, summary: input.summary ?? truncateSummary(input.content ?? null) } as unknown as Record<string, unknown>);
737
594
  let lastError: unknown = null;
738
595
 
739
596
  for (const selectionSet of selectionSets) {
740
- const createMutation = `
741
- mutation CreateMessage($input: CreateMessageInput!) {
742
- createMessage(input: $input) {
743
- ${selectionSet}
744
- }
745
- }
746
- `;
747
- for (const candidateInput of inputCandidates) {
748
- try {
749
- const response = await getGraphQLClient().graphql({
750
- query: createMutation,
751
- variables: { input: candidateInput },
752
- authMode: USER_POOL_AUTH_MODE,
753
- });
754
- const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "createMessage"));
755
- await maybeUploadConsoleMessageBody(created.id, input.content);
756
- return input.content?.trim() ? { ...created, content: input.content } : created;
757
- } catch (error) {
758
- if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "createMessage");
759
- lastError = error;
760
- }
761
- }
762
- }
763
-
764
- if (lastError) {
765
597
  try {
766
598
  const response = await getGraphQLClient().graphql({
767
- query: CREATE_CONSOLE_MESSAGE_LEGACY,
768
- variables: {
769
- input: inputCandidates[inputCandidates.length - 1] ?? {},
770
- },
599
+ query: postMessageMutation(args, selectionSet),
600
+ variables: args,
771
601
  authMode: USER_POOL_AUTH_MODE,
772
602
  });
773
- const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "createMessage"));
603
+ const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "postMessage"));
774
604
  await maybeUploadConsoleMessageBody(created.id, input.content);
775
605
  return input.content?.trim() ? { ...created, content: input.content } : created;
776
606
  } catch (error) {
777
- if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "createMessage");
607
+ if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "postMessage");
778
608
  lastError = error;
779
609
  }
780
610
  }
781
611
 
782
- throw normalizeUnknownError(lastError, "createMessage");
612
+ throw normalizeUnknownError(lastError, "postMessage");
783
613
  }
784
614
 
785
615
  export async function updateConsoleMessage(
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Messages are created through the `postMessage` mutation, never through `createMessage`: the backend stamps the author from the
3
+ * signed-in identity, so a browser cannot name one. This is the one place that turns a Message record into that mutation and its
4
+ * arguments, shared by the conversation, forum and console chat writers. Authorship and timestamps are left out on purpose.
5
+ */
6
+
7
+ const ARGUMENT_TYPES = {
8
+ id: "ID!",
9
+ threadId: "ID!",
10
+ content: "String",
11
+ messageKind: "String!",
12
+ messageDomain: "String!",
13
+ summary: "String",
14
+ parentMessageId: "ID",
15
+ sequenceNumber: "Int",
16
+ messageType: "String",
17
+ role: "String",
18
+ source: "String",
19
+ authorLabel: "String",
20
+ semanticLayer: "String",
21
+ searchVisibility: "String",
22
+ responseTarget: "String",
23
+ responseStatus: "String",
24
+ responseOwner: "String",
25
+ responseStartedAt: "AWSDateTime",
26
+ responseCompletedAt: "AWSDateTime",
27
+ metadata: "AWSJSON",
28
+ newsroomFeedKey: "String",
29
+ } as const;
30
+
31
+ export type PostMessageArguments = Partial<Record<keyof typeof ARGUMENT_TYPES, string | number | null>>;
32
+
33
+ type MessageLike = Record<string, unknown>;
34
+
35
+ /** The arguments of `postMessage` from a Message record. Anything else on the record (authorSub, authorUserProfileId, createdAt, updatedAt) is dropped. */
36
+ export function postMessageArguments(record: MessageLike): PostMessageArguments {
37
+ const args: PostMessageArguments = {};
38
+ for (const key of Object.keys(ARGUMENT_TYPES) as Array<keyof typeof ARGUMENT_TYPES>) {
39
+ let value = record[key === "content" ? "content" : key];
40
+ if (key === "content") value = record.content ?? record.body;
41
+ if (value === undefined || value === null) continue;
42
+ if (key === "metadata") value = typeof value === "string" ? value : JSON.stringify(value);
43
+ args[key] = value as string | number;
44
+ }
45
+ return args;
46
+ }
47
+
48
+ /** `mutation PostMessage($id: ID!, …) { postMessage(id: $id, …) { <selection> } }` declaring exactly the arguments given. */
49
+ export function postMessageMutation(args: PostMessageArguments, selection: string): string {
50
+ const keys = Object.keys(args) as Array<keyof typeof ARGUMENT_TYPES>;
51
+ const declared = keys.map((key) => `$${key}: ${ARGUMENT_TYPES[key]}`).join(", ");
52
+ const passed = keys.map((key) => `${key}: $${key}`).join(", ");
53
+ return `mutation PostMessage(${declared}) { postMessage(${passed}) { ${selection} } }`;
54
+ }
package/package.json CHANGED
@@ -327,6 +327,7 @@
327
327
  "./lib/papyrus-web-locations": "./lib/papyrus-web-locations.ts",
328
328
  "./lib/pilobol-sample": "./lib/pilobol-sample.ts",
329
329
  "./lib/plugin-settings": "./lib/plugin-settings.ts",
330
+ "./lib/post-message": "./lib/post-message.ts",
330
331
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
331
332
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
332
333
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
@@ -517,7 +518,7 @@
517
518
  "optional": true
518
519
  }
519
520
  },
520
- "version": "5.43.0-next.49",
521
+ "version": "5.43.0-next.50",
521
522
  "dependencies": {
522
523
  "@aws-amplify/adapter-nextjs": "^1.7.3",
523
524
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.43.0-next.49",
2
+ "version": "5.43.0-next.50",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",