|
23 | 23 | * verified with a caller-scoped `findOne`, so the parent's own |
24 | 24 | * OWD/sharing, RLS and object-level CRUD all apply. `author_id` is |
25 | 25 | * server-stamped from the session; a client-supplied value never wins. |
| 26 | + * So is the display identity beside it: `author_name` and |
| 27 | + * `author_avatar_url` are the session user's own name and avatar |
| 28 | + * ({@link CommentAuthorIdentityResolver}), never the client's, and both |
| 29 | + * are read-only on update (`objects/sys-comment.object.ts`). |
26 | 30 | * * `beforeUpdate` / `beforeDelete`: the caller must be the comment's |
27 | 31 | * AUTHOR, or hold EDIT on the parent record (see "Parent EDIT" below) — |
28 | 32 | * the attachment kit's uploader-or-parent-editor rule. A multi-row write |
@@ -221,6 +225,24 @@ export interface CommentAccessLogger { |
221 | 225 | debug?(msg: string, meta?: unknown): void; |
222 | 226 | } |
223 | 227 |
|
| 228 | +/** A comment author's display identity: the name and avatar URL the platform |
| 229 | + * holds for that user, each `null` where it cannot be read. */ |
| 230 | +export interface CommentAuthorIdentity { |
| 231 | + name: string | null; |
| 232 | + avatarUrl: string | null; |
| 233 | +} |
| 234 | + |
| 235 | +/** |
| 236 | + * Resolves the display identity of the user a comment is stamped with, given |
| 237 | + * the write's hook context. `AuditPlugin` wires the audit writers' actor |
| 238 | + * snapshot (`AuditWritersHandle.resolveActorSnapshot`), the resolution the |
| 239 | + * `sys_activity` actor columns are written with, so a user reads as the same |
| 240 | + * name in a comment and in the record's history. |
| 241 | + */ |
| 242 | +export type CommentAuthorIdentityResolver = (ctx: any, userId: string) => Promise<CommentAuthorIdentity>; |
| 243 | + |
| 244 | +const NO_AUTHOR_IDENTITY: CommentAuthorIdentity = { name: null, avatarUrl: null }; |
| 245 | + |
224 | 246 | /** Must match `AuditPlugin.name` — hooks are removable by owning package. */ |
225 | 247 | const PACKAGE_ID = 'com.objectstack.audit'; |
226 | 248 | const SYSTEM_CTX = { isSystem: true } as const; |
@@ -709,14 +731,45 @@ async function callerCanRead(ctx: any, target: CommentThreadTarget): Promise<boo |
709 | 731 | * asked on a record-sharing abstention instead; absent too, a |
710 | 732 | * `controlled_by_parent` parent meets no master check here, exactly as its own |
711 | 733 | * update meets none. |
| 734 | + * |
| 735 | + * `resolveAuthorIdentity` answers the display identity (name, avatar) a new |
| 736 | + * comment is stamped with, for the session's user. Absent, a comment is stored |
| 737 | + * with neither, never with the client's: the display identity is the server's |
| 738 | + * or none. That composition is said once, here, at install. |
712 | 739 | */ |
713 | 740 | export function installCommentAccessHooks( |
714 | 741 | engine: CommentAccessEngine, |
715 | 742 | getSharing: () => CommentSharingLike | null | undefined, |
716 | 743 | logger: CommentAccessLogger, |
717 | 744 | messageTranslator?: () => ValidationMessageTranslator | undefined, |
718 | 745 | getSecurity?: () => CommentSecurityLike | null | undefined, |
| 746 | + resolveAuthorIdentity?: CommentAuthorIdentityResolver, |
719 | 747 | ): void { |
| 748 | + if (!resolveAuthorIdentity) { |
| 749 | + logger.warn( |
| 750 | + '[audit] comment access: no author identity resolver is composed — new comments store no author_name / ' + |
| 751 | + 'author_avatar_url (readers show them as unknown). AuditPlugin wires one; a host calling ' + |
| 752 | + 'installCommentAccessHooks directly passes `resolveAuthorIdentity`.', |
| 753 | + ); |
| 754 | + } |
| 755 | + |
| 756 | + /** The display identity of the session's user, the server's own answer: a |
| 757 | + * session with no user, a resolver that is absent or fails, and a value that |
| 758 | + * is not a non-blank string all answer `null`, never the client's value. */ |
| 759 | + const authorIdentityOf = async (ctx: any): Promise<CommentAuthorIdentity> => { |
| 760 | + const userId: unknown = ctx.session.userId; |
| 761 | + if ((typeof userId !== 'string' && typeof userId !== 'number') || String(userId) === '') return NO_AUTHOR_IDENTITY; |
| 762 | + if (!resolveAuthorIdentity) return NO_AUTHOR_IDENTITY; |
| 763 | + let identity: CommentAuthorIdentity | null | undefined; |
| 764 | + try { |
| 765 | + identity = await resolveAuthorIdentity(ctx, String(userId)); |
| 766 | + } catch { |
| 767 | + return NO_AUTHOR_IDENTITY; |
| 768 | + } |
| 769 | + const text = (value: unknown): string | null => (typeof value === 'string' && value.trim() ? value : null); |
| 770 | + return { name: text(identity?.name), avatarUrl: text(identity?.avatarUrl) }; |
| 771 | + }; |
| 772 | + |
720 | 773 | /** May the caller EDIT the parent record behind `target`? The composition |
721 | 774 | * "Parent EDIT" in the module header sets out. [#24646] Where the security |
722 | 775 | * service serves `explain`, the parent's own by-id update judgement decides |
@@ -891,6 +944,16 @@ export function installCommentAccessHooks( |
891 | 944 | target.object, |
892 | 945 | ); |
893 | 946 | } |
| 947 | + |
| 948 | + // The display identity is the server's too, exactly as `author_id` |
| 949 | + // above: the name and avatar a reader is shown are the session user's |
| 950 | + // own, assigned over whatever the client sent, and assigned when it sent |
| 951 | + // nothing. Asked only once the comment is admitted, so a refused write |
| 952 | + // reads no identity. On update both are read-only |
| 953 | + // (`objects/sys-comment.object.ts`), so they stay the creator's. |
| 954 | + const identity = await authorIdentityOf(ctx); |
| 955 | + data.author_name = identity.name; |
| 956 | + data.author_avatar_url = identity.avatarUrl; |
894 | 957 | }; |
895 | 958 | engine.registerHook('beforeInsert', onInsert, { object: COMMENT_OBJECT, packageId: PACKAGE_ID }); |
896 | 959 | engine.registerHook('beforeInsert', onInsert, { object: COMMENT_REACTION_OBJECT, packageId: PACKAGE_ID }); |
|
0 commit comments