From 9b470b7871e3866c3c961bd00462842a600cc186 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 2 Jun 2026 15:52:17 +0100 Subject: [PATCH 1/3] fix(pad): attribute default welcome text to the system author (#7885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user opens a brand-new pad, CLIENT_READY calls getPad(padId, null, session.author), and Pad.init attributed the auto-generated default content (settings.defaultPadText or a padDefaultContent hook substitution) to that author. The welcome text — which the user never wrote — therefore carried the creator's `author` attribute and rendered in their authorship colour. Track whether the initial text came from the default-content path and, if so, attribute the initial changeset to the stable system author (Pad.SYSTEM_AUTHOR_ID) instead of the creating user. Explicitly provided text (e.g. HTTP API createPad with text + author) keeps the real author. The creating user becomes a listed author only once they actually type; their "ownership" of the pad (pad-wide settings defaults, the author token) does not depend on owning the default text, so it is unaffected. listAuthorsOfPad already filters out the system author, so the public API reports zero contributors for an untouched default pad. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/node/db/Pad.ts | 24 ++++++++++++++++-------- src/tests/backend/specs/Pad.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index de8e85fddbd..b550fc2cc45 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -590,20 +590,28 @@ class Pad { Object.assign(this, value); if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool); } else { - if (text == null) { + // Auto-generated default content (settings.defaultPadText or whatever a + // padDefaultContent hook substitutes) is not written by the user who + // happens to open the pad first, so it must not carry their author + // attribute — otherwise the welcome text shows up in the creator's + // authorship colour (issue #7885). Track whether the text came from the + // default-content path so it can be attributed to the system author. + const usedDefaultContent = (text == null); + if (usedDefaultContent) { const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText}; await hooks.aCallAll('padDefaultContent', context); if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); text = exports.cleanText(context.content); } - // When the initial pad text is non-empty but no authorId was - // supplied (internal getPad calls during HTTP API setup, - // padDefaultContent flows, plugin-driven pad creation), fall back - // to the stable system author so the initial changeset's insert - // op carries an `author` attribute. Mirrors the same substitution - // setText/appendText already do via spliceText. + // Attribute the initial changeset to the stable system author when the + // content is auto-generated default text, or when non-empty text was + // supplied without an authorId (internal getPad calls during HTTP API + // setup, plugin-driven pad creation). The latter keeps the initial + // insert op carrying an `author` attribute, mirroring the same + // substitution setText/appendText already do via spliceText. const effectiveAuthorId = - (text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId; + (usedDefaultContent || (text.length > 0 && !authorId)) + ? Pad.SYSTEM_AUTHOR_ID : authorId; const firstAttribs = effectiveAuthorId ? [['author', effectiveAuthorId] as [string, string]] : undefined; diff --git a/src/tests/backend/specs/Pad.ts b/src/tests/backend/specs/Pad.ts index d4c4956cb03..d9afe6b62b4 100644 --- a/src/tests/backend/specs/Pad.ts +++ b/src/tests/backend/specs/Pad.ts @@ -179,6 +179,35 @@ describe(__filename, function () { pad = await padManager.getPad(padId); assert.equal(pad!.text(), `${want}\n`); }); + + it('attributes default content to the system author, not the creating user (issue #7885)', + async function () { + // When a user opens a brand-new pad, CLIENT_READY calls + // getPad(padId, null, session.author). The default welcome text is + // not written by that user, so it must not carry their author + // attribute (which would colour it with the creator's colour). + const creator = await authorManager.getAuthorId(`t.${padId}`); + pad = await padManager.getPad(padId, null, creator); + // getAllAuthors is an existing runtime Pad method; cast avoids adding a + // type-only declaration to PadType in this PR (mirrors spliceText above). + const authors: string[] = (pad as any).getAllAuthors(); + assert(!authors.includes(creator), + `default text must not be owned by the creating author ${creator}`); + assert(authors.includes('a.etherpad-system'), + 'default text should be owned by the system author'); + }); + + it('still attributes explicitly provided content to the creating author', + async function () { + // A real author providing real text (e.g. API createPad with text) + // keeps ownership — only the auto-generated default content is + // reassigned to the system author. + const creator = await authorManager.getAuthorId(`t.${padId}`); + pad = await padManager.getPad(padId, 'real user content', creator); + const authors: string[] = (pad as any).getAllAuthors(); + assert(authors.includes(creator), + 'explicitly provided text should remain owned by the creating author'); + }); }); describe('normalizePadSettings lang (issue #7586)', function () { From 0260942df34dc128ce34d8e311e32e67e03f8479 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 2 Jun 2026 16:04:48 +0100 Subject: [PATCH 2/3] fix(pad): keep creator as revision-0 author, only de-colour the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (socketio "Pad-wide settings creator gate") and Qodo both caught that attributing revision 0 to the system author stripped the creating user's ownership: isPadCreator() / the pad-wide settings gate and the deletion token all key off getRevisionAuthor(0). Decouple the two: the initial revision's meta.author stays the real creator (ownership preserved), while only the welcome text's `author` *attribute* — the thing that colours it — becomes Pad.SYSTEM_AUTHOR_ID. The system fallback for the revision author now applies solely when no author was supplied at all. Tests assert both halves: default text is system-coloured (not the creator's colour) AND the creator remains the revision-0 author; explicit text stays coloured with the creator. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/node/db/Pad.ts | 33 +++++++++++++-------- src/tests/backend/specs/Pad.ts | 53 ++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index b550fc2cc45..840770cbcfc 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -592,10 +592,11 @@ class Pad { } else { // Auto-generated default content (settings.defaultPadText or whatever a // padDefaultContent hook substitutes) is not written by the user who - // happens to open the pad first, so it must not carry their author + // happens to open the pad first, so the text must not carry their author // attribute — otherwise the welcome text shows up in the creator's // authorship colour (issue #7885). Track whether the text came from the - // default-content path so it can be attributed to the system author. + // default-content path so its insert op can be attributed to the system + // author. const usedDefaultContent = (text == null); if (usedDefaultContent) { const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText}; @@ -603,20 +604,28 @@ class Pad { if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); text = exports.cleanText(context.content); } - // Attribute the initial changeset to the stable system author when the - // content is auto-generated default text, or when non-empty text was + // The author *attribute* applied to the initial text — i.e. what colours + // it in the editor — is the stable system author when the content is + // auto-generated default text (#7885), or when non-empty text was // supplied without an authorId (internal getPad calls during HTTP API - // setup, plugin-driven pad creation). The latter keeps the initial - // insert op carrying an `author` attribute, mirroring the same - // substitution setText/appendText already do via spliceText. - const effectiveAuthorId = - (usedDefaultContent || (text.length > 0 && !authorId)) + // setup, plugin-driven pad creation). The latter keeps the insert op + // carrying an `author` attribute, mirroring the substitution + // setText/appendText already do via spliceText. + const attribAuthorId = + ((usedDefaultContent || !authorId) && text.length > 0) ? Pad.SYSTEM_AUTHOR_ID : authorId; - const firstAttribs = effectiveAuthorId - ? [['author', effectiveAuthorId] as [string, string]] + const firstAttribs = attribAuthorId + ? [['author', attribAuthorId] as [string, string]] : undefined; + // The *revision* author (revs:0 meta.author) stays the real creator so + // pad ownership is preserved: isPadCreator() / the pad-wide settings gate + // and the deletion token all key off getRevisionAuthor(0). Only when no + // author was supplied at all do we fall back to the system author, so the + // initial revision still records a stable, non-empty author. + const revisionAuthorId = + authorId || (text.length > 0 ? Pad.SYSTEM_AUTHOR_ID : ''); const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool); - await this.appendRevision(firstChangeset, effectiveAuthorId); + await this.appendRevision(firstChangeset, revisionAuthorId); } this.padSettings = Pad.normalizePadSettings(this.padSettings); await hooks.aCallAll('padLoad', {pad: this}); diff --git a/src/tests/backend/specs/Pad.ts b/src/tests/backend/specs/Pad.ts index d9afe6b62b4..eab6eaee4f0 100644 --- a/src/tests/backend/specs/Pad.ts +++ b/src/tests/backend/specs/Pad.ts @@ -180,33 +180,56 @@ describe(__filename, function () { assert.equal(pad!.text(), `${want}\n`); }); - it('attributes default content to the system author, not the creating user (issue #7885)', + // Returns the set of author IDs actually applied to the pad's text, by + // resolving every attribute marker in the current AText against the pool. + // This is what colours the text in the editor — distinct from + // getRevisionAuthor()/getAllAuthors() which also reflect pool bookkeeping. + const authorsAppliedToText = (p: any): Set => { + const applied = new Set(); + const attribs: string = p.atext.attribs; + for (const m of attribs.matchAll(/\*([0-9a-z]+)/g)) { + const attr = p.pool.getAttrib(parseInt(m[1], 36)); + if (attr && attr[0] === 'author' && attr[1] !== '') applied.add(attr[1]); + } + return applied; + }; + + it('does not colour default content with the creating user (issue #7885)', async function () { // When a user opens a brand-new pad, CLIENT_READY calls - // getPad(padId, null, session.author). The default welcome text is - // not written by that user, so it must not carry their author - // attribute (which would colour it with the creator's colour). + // getPad(padId, null, session.author). The default welcome text is not + // written by that user, so its insert op must not carry their author + // attribute (which would colour it in the creator's colour). The system + // author owns the text instead. const creator = await authorManager.getAuthorId(`t.${padId}`); pad = await padManager.getPad(padId, null, creator); - // getAllAuthors is an existing runtime Pad method; cast avoids adding a - // type-only declaration to PadType in this PR (mirrors spliceText above). - const authors: string[] = (pad as any).getAllAuthors(); - assert(!authors.includes(creator), - `default text must not be owned by the creating author ${creator}`); - assert(authors.includes('a.etherpad-system'), + const applied = authorsAppliedToText(pad); + assert(!applied.has(creator), + `default text must not be coloured with the creating author ${creator}`); + assert(applied.has('a.etherpad-system'), 'default text should be owned by the system author'); }); - it('still attributes explicitly provided content to the creating author', + it('keeps the creating user as the revision-0 author so pad ownership is preserved', + async function () { + // isPadCreator()/the pad-wide settings gate and the deletion token all + // key off getRevisionAuthor(0). Reassigning the welcome-text colour to + // the system author (above) must not strip the creator's ownership. + const creator = await authorManager.getAuthorId(`t.${padId}`); + pad = await padManager.getPad(padId, null, creator); + assert.equal(await (pad as any).getRevisionAuthor(0), creator, + 'the creating user must remain the revision-0 author'); + }); + + it('still colours explicitly provided content with the creating author', async function () { // A real author providing real text (e.g. API createPad with text) - // keeps ownership — only the auto-generated default content is + // keeps ownership of that text — only auto-generated default content is // reassigned to the system author. const creator = await authorManager.getAuthorId(`t.${padId}`); pad = await padManager.getPad(padId, 'real user content', creator); - const authors: string[] = (pad as any).getAllAuthors(); - assert(authors.includes(creator), - 'explicitly provided text should remain owned by the creating author'); + assert(authorsAppliedToText(pad).has(creator), + 'explicitly provided text should be coloured with the creating author'); }); }); From fdabf9e3dd32c509d8169eb3ce34cca90b1eab38 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 2 Jun 2026 16:15:24 +0100 Subject: [PATCH 3/3] test(wcag): target the user-authored span, not the system welcome text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wcag_author_color spec picked the first `span[class*="author-"]` on the page to measure the user's colour contrast. With #7885 the default welcome text is now owned by the system author and renders with no background colour, so that first span is no longer the current user's — the contrast read came back as transparent rgba(0,0,0,0) and the three assertions failed. Match the author span by the text we just typed ("contrast smoke") instead, so the test measures the actual user-authored content it intends to. Verified locally: 3/3 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/wcag_author_color.spec.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/tests/frontend-new/specs/wcag_author_color.spec.ts b/src/tests/frontend-new/specs/wcag_author_color.spec.ts index 314591c62aa..27dda3728a7 100644 --- a/src/tests/frontend-new/specs/wcag_author_color.spec.ts +++ b/src/tests/frontend-new/specs/wcag_author_color.spec.ts @@ -36,17 +36,23 @@ const wcagRatio = (rgb1: string, rgb2: string): number => { const renderedAuthorContrast = async (page: Page) => { const body = await getPadBody(page); await body.click(); - await page.keyboard.type('contrast smoke'); + const typed = 'contrast smoke'; + await page.keyboard.type(typed); await page.waitForTimeout(300); - // The author span is the inner-frame wrapping - // the typed text. Read its computed bg + the inherited text colour. - const result = await page.frame('ace_inner')!.evaluate(() => { - const span = document.querySelector( - '#innerdocbody span[class*="author-"]:not([class*="anonymous"])') as HTMLElement | null; + // The author span is the inner-frame wrapping the + // text WE just typed. Match by text content rather than picking the first + // author span on the page: the default welcome text is owned by the system + // author (issue #7885) and renders with no background colour, so the first + // author span is no longer the current user's. Read the span's computed bg + + // the inherited text colour. + const result = await page.frame('ace_inner')!.evaluate((needle) => { + const spans = Array.from( + document.querySelectorAll('#innerdocbody span[class*="author-"]')) as HTMLElement[]; + const span = spans.find((s) => (s.textContent || '').includes(needle)); if (!span) return null; const cs = getComputedStyle(span); return {bg: cs.backgroundColor, color: cs.color}; - }); + }, typed); return result; };