@anthusai/papyrus 5.58.0 → 5.59.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 (52) hide show
  1. package/amplify/data/message-actions.js +68 -0
  2. package/amplify/data/message-actions.ts +70 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +8 -5
  9. package/amplify/data/resource.ts +8 -5
  10. package/amplify/data/schema.js +14 -7
  11. package/amplify/data/schema.ts +14 -7
  12. package/components/conversation-context.tsx +29 -2
  13. package/components/conversation-provider.tsx +5 -1
  14. package/components/conversation-thread.tsx +133 -19
  15. package/components/conversations-memory-source.ts +43 -6
  16. package/components/conversations-network-source.ts +13 -1
  17. package/components/conversations-source.ts +15 -3
  18. package/components/conversations-view.tsx +437 -76
  19. package/components/list-detail-layout.tsx +44 -0
  20. package/components/needs-me-bodies.tsx +14 -14
  21. package/components/needs-me-feed.tsx +8 -18
  22. package/components/news-desk-taxonomy-client.ts +142 -57
  23. package/components/newsroom-article-editor.tsx +10 -4
  24. package/components/newsroom-organizer.css +10 -1
  25. package/components/newsroom-references-view.tsx +41 -100
  26. package/components/notification-cards.tsx +4 -4
  27. package/components/notification-center.tsx +5 -7
  28. package/components/proposals-view.tsx +4 -4
  29. package/components/reference-conversation.tsx +5 -0
  30. package/components/reference-feed.tsx +69 -85
  31. package/components/reference-review-card.tsx +1 -1
  32. package/components/references-page-loading.tsx +12 -6
  33. package/components/topic-steering-workspace.tsx +4 -4
  34. package/components/use-conversation.ts +36 -2
  35. package/components/use-feed-preferences.ts +33 -0
  36. package/lib/category-repository.ts +7 -38
  37. package/lib/console-chat-client.ts +8 -178
  38. package/lib/conversation.ts +73 -2
  39. package/lib/needs-me-items.ts +4 -4
  40. package/lib/needs-me-ranking.ts +9 -22
  41. package/lib/newsroom-board.ts +10 -1
  42. package/lib/newsroom-demo-dashboard.ts +0 -7
  43. package/lib/newsroom-organizer-nav.ts +2 -2
  44. package/lib/post-message.ts +54 -0
  45. package/lib/proposals-admin.ts +2 -2
  46. package/lib/reference-feed-ranking.ts +77 -160
  47. package/lib/review-drafts.ts +2 -2
  48. package/lib/review-fixture.ts +1 -1
  49. package/lib/review-requests.ts +4 -4
  50. package/lib/semantic-graph.ts +1 -1
  51. package/package.json +4 -1
  52. package/routes.manifest.json +1 -1
@@ -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
+ }
@@ -1575,6 +1575,9 @@ const schema = a.schema({
1575
1575
  curationDecisionRelationId: a.string(),
1576
1576
  newsroomFeedKey: a.string(),
1577
1577
  reviewedFeedKey: a.string(),
1578
+ discoveredByMonitorId: a.string(),
1579
+ discoveryDetails: a.json(),
1580
+ discoveryRule: a.json(),
1578
1581
  // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1579
1582
  // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1580
1583
  // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
@@ -1596,6 +1599,7 @@ const schema = a.schema({
1596
1599
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1597
1600
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1598
1601
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1602
+ index("discoveredByMonitorId").sortKeys(["createdAt"]).queryField("listReferencesByMonitorAndCreatedAt"),
1599
1603
  index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1600
1604
  index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1601
1605
  ])
@@ -1722,12 +1726,11 @@ const schema = a.schema({
1722
1726
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1723
1727
  ])
1724
1728
  .authorization((allow) => [
1725
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1726
- allow.group("editor").to(categoryAppendOnlyOperations),
1727
- 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"]),
1728
1733
  allow.authenticated("identityPool").to(["read", "create", "update"]),
1729
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1730
- allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1731
1734
  ]),
1732
1735
  MessageThread: a
1733
1736
  .model({
@@ -1688,6 +1688,9 @@ const schema = a.schema({
1688
1688
  curationDecisionRelationId: a.string(),
1689
1689
  newsroomFeedKey: a.string(),
1690
1690
  reviewedFeedKey: a.string(),
1691
+ discoveredByMonitorId: a.string(),
1692
+ discoveryDetails: a.json(),
1693
+ discoveryRule: a.json(),
1691
1694
  // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1692
1695
  // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1693
1696
  // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
@@ -1709,6 +1712,7 @@ const schema = a.schema({
1709
1712
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1710
1713
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1711
1714
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1715
+ index("discoveredByMonitorId").sortKeys(["createdAt"]).queryField("listReferencesByMonitorAndCreatedAt"),
1712
1716
  index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1713
1717
  index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1714
1718
  ])
@@ -1838,12 +1842,11 @@ const schema = a.schema({
1838
1842
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1839
1843
  ])
1840
1844
  .authorization((allow) => [
1841
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1842
- allow.group("editor").to(categoryAppendOnlyOperations),
1843
- 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"]),
1844
1849
  allow.authenticated("identityPool").to(["read", "create", "update"]),
1845
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1846
- allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1847
1850
  ]),
1848
1851
 
1849
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";
@@ -1481,6 +1482,9 @@ function buildBaseDefinitions(includeEditions) {
1481
1482
  curationDecisionRelationId: a.string(),
1482
1483
  newsroomFeedKey: a.string(),
1483
1484
  reviewedFeedKey: a.string(),
1485
+ discoveredByMonitorId: a.string(),
1486
+ discoveryDetails: a.json(),
1487
+ discoveryRule: a.json(),
1484
1488
  // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1485
1489
  // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1486
1490
  // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
@@ -1502,6 +1506,7 @@ function buildBaseDefinitions(includeEditions) {
1502
1506
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1503
1507
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1504
1508
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1509
+ index("discoveredByMonitorId").sortKeys(["createdAt"]).queryField("listReferencesByMonitorAndCreatedAt"),
1505
1510
  index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1506
1511
  index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1507
1512
  ])
@@ -1625,23 +1630,24 @@ function buildBaseDefinitions(includeEditions) {
1625
1630
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1626
1631
  ])
1627
1632
  .authorization((allow) => [
1628
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1629
- allow.group("editor").to(categoryAppendOnlyOperations),
1630
- allow.group(adminGroup).to(["read", "create", "delete"]),
1631
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1632
- 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"]),
1633
1637
  ]),
1634
1638
  MessageThread: realtimeModel({
1635
1639
  id: a.id().required(),
1636
1640
  threadKind: a.string().required(),
1637
1641
  status: a.string().required(),
1638
- 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"])]),
1639
1645
  summary: a.string(),
1640
1646
  primaryAnchorKind: a.string(),
1641
1647
  primaryAnchorId: a.id(),
1642
1648
  primaryAnchorLineageId: a.id(),
1643
1649
  primaryAnchorKey: a.string(),
1644
- createdBySub: a.string(),
1650
+ createdBySub: a.string().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1645
1651
  createdByUserProfileId: a.id(),
1646
1652
  createdByLabel: a.string(),
1647
1653
  messageCount: a.integer(),
@@ -2126,6 +2132,7 @@ function buildBaseDefinitions(includeEditions) {
2126
2132
  ...monitoringViewSchemaDefinitions,
2127
2133
  ...automationViewSchemaDefinitions,
2128
2134
  ...proposalActionsSchemaDefinitions,
2135
+ ...messageActionsSchemaDefinitions,
2129
2136
  };
2130
2137
  if (!includeEditions) {
2131
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";
@@ -1592,6 +1593,9 @@ const definitions = {
1592
1593
  curationDecisionRelationId: a.string(),
1593
1594
  newsroomFeedKey: a.string(),
1594
1595
  reviewedFeedKey: a.string(),
1596
+ discoveredByMonitorId: a.string(),
1597
+ discoveryDetails: a.json(),
1598
+ discoveryRule: a.json(),
1595
1599
  // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1596
1600
  // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1597
1601
  // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
@@ -1613,6 +1617,7 @@ const definitions = {
1613
1617
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1614
1618
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1615
1619
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1620
+ index("discoveredByMonitorId").sortKeys(["createdAt"]).queryField("listReferencesByMonitorAndCreatedAt"),
1616
1621
  index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1617
1622
  index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1618
1623
  ])
@@ -1739,24 +1744,25 @@ const definitions = {
1739
1744
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1740
1745
  ])
1741
1746
  .authorization((allow) => [
1742
- // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1743
- allow.group("editor").to(categoryAppendOnlyOperations),
1744
- allow.group(adminGroup).to(["read", "create", "delete"]),
1745
- // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1746
- 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"]),
1747
1751
  ]),
1748
1752
 
1749
1753
  MessageThread: realtimeModel({
1750
1754
  id: a.id().required(),
1751
1755
  threadKind: a.string().required(),
1752
1756
  status: a.string().required(),
1753
- 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"])]),
1754
1760
  summary: a.string(),
1755
1761
  primaryAnchorKind: a.string(),
1756
1762
  primaryAnchorId: a.id(),
1757
1763
  primaryAnchorLineageId: a.id(),
1758
1764
  primaryAnchorKey: a.string(),
1759
- createdBySub: a.string(),
1765
+ createdBySub: a.string().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1760
1766
  createdByUserProfileId: a.id(),
1761
1767
  createdByLabel: a.string(),
1762
1768
  messageCount: a.integer(),
@@ -2256,6 +2262,7 @@ const definitions = {
2256
2262
  ...monitoringViewSchemaDefinitions,
2257
2263
  ...automationViewSchemaDefinitions,
2258
2264
  ...proposalActionsSchemaDefinitions,
2265
+ ...messageActionsSchemaDefinitions,
2259
2266
  };
2260
2267
  if (!includeEditions) {
2261
2268
  const mutableDefinitions = definitions as Record<string, unknown>;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, useContext, type ReactNode } from "react";
4
- import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
4
+ import { NO_VIEWER, canDeleteMessage, canEditMessage, type ConversationAnchor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
5
5
 
6
6
  /**
7
7
  * Where conversations are read and written. The newsroom provides the network one; a demo or a test provides the in-memory one. A
@@ -14,6 +14,11 @@ export type ConversationStore = {
14
14
  append: (input: { anchor: ConversationAnchor; body: string; parentMessageId: string | null }) => Promise<ConversationMessage>;
15
15
  /** Message counts by Reference lineage, for the feed cards. */
16
16
  counts: () => Promise<Map<string, number>>;
17
+ /** Who is looking, and display names by profile id: what Edit, Delete and the author's name are decided from. */
18
+ viewer: () => Promise<ConversationViewer>;
19
+ authors: (profileIds: string[]) => Promise<Map<string, string>>;
20
+ edit: (messageId: string, body: string) => Promise<ConversationMessage>;
21
+ remove: (messageId: string, moderate: boolean) => Promise<ConversationMessage>;
17
22
  };
18
23
 
19
24
  const ConversationStoreContext = createContext<ConversationStore | null>(null);
@@ -27,11 +32,32 @@ export function useConversationStore(): ConversationStore | null {
27
32
  }
28
33
 
29
34
  /** A store that keeps everything in memory: the demo newsroom, and the tests. */
30
- export function createMemoryConversationStore(authorLabel: string, seed: Record<string, ConversationMessage[]> = {}): ConversationStore {
35
+ export function createMemoryConversationStore(authorLabel: string, seed: Record<string, ConversationMessage[]> = {}, who: { viewer?: ConversationViewer; profiles?: Record<string, string> } = {}): ConversationStore {
36
+ const viewer = who.viewer ?? NO_VIEWER;
31
37
  const byAnchor = new Map<string, ConversationMessage[]>(Object.entries(seed));
32
38
  const keyOf = (anchor: ConversationAnchor) => `${anchor.kind}#${anchor.lineageId ?? ""}`;
39
+ const change = (messageId: string, apply: (message: ConversationMessage) => ConversationMessage) => {
40
+ for (const [key, list] of byAnchor) {
41
+ const found = list.find((message) => message.id === messageId);
42
+ if (!found) continue;
43
+ const changed = apply(found);
44
+ byAnchor.set(key, list.map((message) => (message.id === messageId ? changed : message)));
45
+ return changed;
46
+ }
47
+ throw new Error("That message is not here.");
48
+ };
33
49
  return {
34
50
  authorLabel,
51
+ viewer: async () => viewer,
52
+ authors: async (ids) => new Map(ids.filter((id) => who.profiles?.[id]).map((id) => [id, who.profiles![id]])),
53
+ edit: async (messageId, body) => change(messageId, (message) => {
54
+ if (!canEditMessage(viewer, message)) throw new Error("Only the author can edit a message.");
55
+ return { ...message, content: body.trim(), editedAt: new Date().toISOString() };
56
+ }),
57
+ remove: async (messageId) => change(messageId, (message) => {
58
+ if (!canDeleteMessage(viewer, message)) throw new Error("Only the author or an admin can delete a message.");
59
+ return { ...message, content: "", deleted: true, deletedAt: new Date().toISOString() };
60
+ }),
35
61
  load: async (anchor) => [...(byAnchor.get(keyOf(anchor)) ?? [])],
36
62
  append: async ({ anchor, body, parentMessageId }) => {
37
63
  const list = byAnchor.get(keyOf(anchor)) ?? [];
@@ -40,6 +66,7 @@ export function createMemoryConversationStore(authorLabel: string, seed: Record<
40
66
  id: `message-memory-${sequenceNumber}-${list.length}`,
41
67
  parentMessageId,
42
68
  authorLabel,
69
+ authorSub: viewer.sub,
43
70
  content: body.trim(),
44
71
  createdAt: new Date().toISOString(),
45
72
  sequenceNumber,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useMemo, type ReactNode } from "react";
4
4
  import { ConversationStoreProvider, createMemoryConversationStore, type ConversationStore } from "./conversation-context";
5
- import { appendConversationMessage, loadConversationMessages, loadReferenceConversationCounts } from "./news-desk-taxonomy-client";
5
+ import { appendConversationMessage, deleteConversationMessage, editConversationMessage, loadConversationMessages, loadConversationViewer, loadReferenceConversationCounts, loadUserProfileNames } from "./news-desk-taxonomy-client";
6
6
 
7
7
  function networkConversationStore(authorLabel: string): ConversationStore {
8
8
  return {
@@ -10,6 +10,10 @@ function networkConversationStore(authorLabel: string): ConversationStore {
10
10
  load: loadConversationMessages,
11
11
  append: async ({ anchor, body, parentMessageId }) => (await appendConversationMessage({ anchor, body, parentMessageId })).message,
12
12
  counts: loadReferenceConversationCounts,
13
+ viewer: loadConversationViewer,
14
+ authors: loadUserProfileNames,
15
+ edit: editConversationMessage,
16
+ remove: (messageId, moderate) => deleteConversationMessage(messageId, moderate),
13
17
  };
14
18
  }
15
19