diff --git a/e2e/tests/social-messages.spec.ts b/e2e/tests/social-messages.spec.ts new file mode 100644 index 00000000..1647ed4d --- /dev/null +++ b/e2e/tests/social-messages.spec.ts @@ -0,0 +1,399 @@ +import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; +import { v3Login, v3Signup, API_BASE } from '../v3-helpers'; + +/** + * Social app — Messages surface (the /messages/* route, src/data/dms.ts + + * src/components/Chat/). + * + * The API floor pins the app's exact DM pattern: the DM group is created with + * the deterministic NAME dm-{sorted} (invite_only, one member role, both + * participants as bare-username members) and the group_id the API derives + * ({provider}/groups/users/{creator}/{name} — the creator is embedded in the + * ID, so it is NOT symmetric). The recipient therefore finds the group by the + * name suffix in their own group list — the app's exact read pattern — and + * messages are `posts` docs in that group. The I3 anti-test proves a third + * user cannot read the DM group. + * + * The browser gauntlet drives the real two-user round-trip through the real + * app: A composes to B by username (the picker's compose path — the first + * send creates the group), B's context sees the conversation in the list and + * the message in the thread, B replies, A's reload sees the reply. Console + * log sequence verified on both sides ([social-dms] seam logs). + * + * The social app has no P2P — CRUD is the delivery path, so "receive" is the + * recipient's group read (list load / conversation open / reload), not a push. + */ + +const port = process.env.E2E_HTTP_PORT || '80'; +const p = port === '80' ? '' : `:${port}`; +const API_BASE_URL = API_BASE; +const AUTH_BASE = `http://auth.localhost${p}`; +const SOCIAL_BASE = `http://social.localhost${p}`; +const PROVIDER = 'api.localhost'; +const SERVICE = 'posts'; +const ORIGIN = SOCIAL_BASE; + +const uniqueUser = (prefix: string) => `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; +const password = 'TestPass123!'; + +// The app's deterministic DM group name (src/data/dms.ts dmGroupName) — +// sorted, so both parties derive the same name. +const dmGroupName = (a: string, b: string) => `dm-${[a, b].sort().join('-')}`; + +// The app's DM group contract (src/data/dms.ts DM_ROLES, KB: +// groups/social-contracts.md §5): invite_only, one role, equal members. +const DM_ROLES = [ + { + name: 'member', + services: ['posts', 'comments'], + permissions: ['readAll', 'create', 'updateOwn', 'deleteOwn'], + }, +]; + +// The exact app contract the social app's login popup grants +// (src/interfaces/auth.ts SOCIAL_SERVICES × SOCIAL_OPERATIONS). +const SOCIAL_SERVICES = [ + 'posts', + 'media', + 'public_media', + 'profile', + 'settings', + 'comments', + 'reactions', + 'contacts', + 'staging_posts', +]; +const SOCIAL_OPERATIONS = ['create', 'readAll', 'updateOwn', 'deleteOwn']; + +async function signupAndLogin(request: APIRequestContext, prefix: string): Promise<{ username: string; token: string }> { + const username = uniqueUser(prefix); + await v3Signup(request, username, password, '+1555' + Math.floor(Math.random() * 10000000)); + const token = await v3Login(request, username, password); + return { username, token }; +} + +async function addSocialAppContract(request: APIRequestContext, token: string) { + const res = await request.post(`${API_BASE_URL}/v3/app-contracts/add`, { + data: JSON.stringify({ + token, + allowed_origin: ORIGIN, + permissions: Object.fromEntries(SOCIAL_SERVICES.map((s) => [s, [...SOCIAL_OPERATIONS]])), + }), + headers: { 'Content-Type': 'application/json', Origin: AUTH_BASE }, + }); + expect(res.ok(), `app contract add failed (${res.status})`).toBeTruthy(); +} + +/** Create the DM group exactly the way the app does (dms.ts ensureDmGroup). */ +async function rawCreateDmGroup( + request: APIRequestContext, + creatorToken: string, + a: string, + b: string, +): Promise { + const res = await request.post(`${API_BASE_URL}/v3/groups/create`, { + data: JSON.stringify({ + token: creatorToken, + name: dmGroupName(a, b), + join_policy: 'invite_only', + roles: DM_ROLES, + members: [ + { member_key: a, role: 'member' }, + { member_key: b, role: 'member' }, + ], + }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(res.ok(), `DM group create failed (${res.status})`).toBeTruthy(); + return (await res.json()).group_id as string; +} + +async function sendDmDoc( + request: APIRequestContext, + token: string, + groupId: string, + from: string, + to: string, + message: string, +): Promise { + const res = await request.post(`${API_BASE_URL}/v3/create`, { + data: JSON.stringify({ + token, + service: SERVICE, + body: { + message, + sender_username: from, + sender_provider: PROVIDER, + recipient_username: to, + recipient_provider: PROVIDER, + media_refs: [], + }, + groups: [groupId], + }), + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + }); + expect(res.ok(), `DM doc create failed (${res.status})`).toBeTruthy(); + return (await res.json()).doc_id as string; +} + +async function readGroupDocs(request: APIRequestContext, token: string, groupId: string) { + const res = await request.post(`${API_BASE_URL}/v3/read`, { + data: JSON.stringify({ token, service: SERVICE, groups: [groupId] }), + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + }); + expect(res.ok(), `DM group read failed (${res.status})`).toBeTruthy(); + return (await res.json()) as any[]; +} + +async function myGroups(request: APIRequestContext, token: string) { + const res = await request.post(`${API_BASE_URL}/v3/groups/list`, { + data: JSON.stringify({ token }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(res.ok()).toBeTruthy(); + return (await res.json()) as any[]; +} + +/** The app's exact group resolution: find the DM group by name suffix. */ +function findDmGroup(groups: any[], me: string, other: string) { + const suffix = `/${dmGroupName(me, other)}`; + return groups.find((g) => g.group_id.endsWith(suffix)) || null; +} + +function setTokenCookie(context: any, domain: string, token: string) { + return context.addCookies([ + { name: 'token', value: token, domain, path: '/', secure: false, httpOnly: false }, + ]); +} + +function captureConsoleLogs(page: Page, prefix: string): string[] { + const logs: string[] = []; + page.on('console', (msg) => { + const text = msg.text(); + if (text.includes(prefix)) logs.push(text); + }); + return logs; +} + +// toContainText on a multi-element locator violates strict mode, so assert on +// the collected texts instead. toPass keeps the retry/timeout for messages +// that appear asynchronously (after a send / a reload). +async function expectThreadContains(page: Page, text: string, timeout = 15000) { + await expect(async () => { + const texts = await page.locator('[data-testid="dm-message"]').allTextContents(); + expect(texts.some((t) => t.includes(text))).toBeTruthy(); + }).toPass({ timeout }); +} + +// --------------------------------------------------------------------------- +// API floor — the app's exact DM pattern (deterministic name + CRUD + I3) +// --------------------------------------------------------------------------- + +test.describe('Social messages — API floor (DM group contract + CRUD)', () => { + test('round-trip: A creates the group, sends, B finds it by name and reads back, B replies', async ({ request }) => { + const A = await signupAndLogin(request, 'smga'); + const B = await signupAndLogin(request, 'smgb'); + await addSocialAppContract(request, A.token); + await addSocialAppContract(request, B.token); + + // A's first send creates the DM group (the app's ensureDmGroup path). + const groupId = await rawCreateDmGroup(request, A.token, A.username, B.username); + // The API derives the group_id from the caller's token — the creator is + // embedded in the ID (not symmetric; the name is the deterministic part). + expect(groupId).toBe(`${PROVIDER}/groups/users/${A.username}/${dmGroupName(A.username, B.username)}`); + + // A sends to B (a posts doc in the group, the app's sendDm body shape). + await sendDmDoc(request, A.token, groupId, A.username, B.username, 'hello from A'); + + // B's exact read pattern: list my groups, find the DM group by the + // deterministic name suffix, read posts in it. + const bGroups = await myGroups(request, B.token); + const bDm = findDmGroup(bGroups, B.username, A.username); + expect(bDm, 'B must find A-created DM group by the deterministic name').toBeTruthy(); + expect(bDm!.group_id).toBe(groupId); + + const bInbox = await readGroupDocs(request, B.token, groupId); + expect(bInbox.length).toBe(1); + expect(bInbox[0].body.message).toBe('hello from A'); + expect(bInbox[0].author_key).toBe(A.username); + + // B replies in the SAME group (no second group). + await sendDmDoc(request, B.token, groupId, B.username, A.username, 'hi from B'); + + const aInbox = await readGroupDocs(request, A.token, groupId); + expect(aInbox.length).toBe(2); + const texts = aInbox.map((d) => d.body.message).sort(); + expect(texts).toEqual(['hello from A', 'hi from B']); + }); + + test('deterministic name: exactly ONE DM group for the pair, found by both sides', async ({ request }) => { + const A = await signupAndLogin(request, 'smgc'); + const B = await signupAndLogin(request, 'smgd'); + await addSocialAppContract(request, A.token); + await addSocialAppContract(request, B.token); + const groupId = await rawCreateDmGroup(request, A.token, A.username, B.username); + + // Both sides' group lists contain exactly one group with the DM name + // suffix — and it is the same group (not one per direction). + const aGroups = await myGroups(request, A.token); + const aDm = aGroups.filter((g) => g.group_id.endsWith(`/${dmGroupName(A.username, B.username)}`)); + expect(aDm.length).toBe(1); + const bGroups = await myGroups(request, B.token); + const bDm = bGroups.filter((g) => g.group_id.endsWith(`/${dmGroupName(A.username, B.username)}`)); + expect(bDm.length).toBe(1); + expect(aDm[0].group_id).toBe(bDm[0].group_id); + expect(aDm[0].group_id).toBe(groupId); + + // The contract is invite_only with the app's member role (both + // participants equal members — no owner, no hierarchy). + const detail = await request.post(`${API_BASE_URL}/v3/groups/get`, { + data: JSON.stringify({ token: B.token, group_id: groupId }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(detail.ok()).toBeTruthy(); + const g = await detail.json(); + expect(g.join_policy).toBe('invite_only'); + expect(g.roles).toEqual(DM_ROLES); + }); + + test('anti-test: a third user cannot read the DM group (I3 holds)', async ({ request }) => { + const A = await signupAndLogin(request, 'smge'); + const B = await signupAndLogin(request, 'smgf'); + const C = await signupAndLogin(request, 'smgg'); + await addSocialAppContract(request, A.token); + await addSocialAppContract(request, B.token); + await addSocialAppContract(request, C.token); + const groupId = await rawCreateDmGroup(request, A.token, A.username, B.username); + await sendDmDoc(request, A.token, groupId, A.username, B.username, 'private'); + + // C is not a member of the DM group. + const readRes = await request.post(`${API_BASE_URL}/v3/read`, { + data: JSON.stringify({ token: C.token, service: SERVICE, groups: [groupId] }), + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + }); + expect(readRes.status()).toBe(403); + const err = await readRes.json(); + expect(err.detail).toMatch(/not a member/i); + }); +}); + +// --------------------------------------------------------------------------- +// Browser gauntlet — the real two-user DM round-trip through the real app +// --------------------------------------------------------------------------- + +test.describe('Social messages gauntlet — two-user DM round-trip', () => { + test('A composes to B (first send creates the group) → B receives → B replies → A sees the reply', async ({ browser, request }) => { + test.setTimeout(90000); + const A = await signupAndLogin(request, 'smga1'); + const B = await signupAndLogin(request, 'smgb1'); + await addSocialAppContract(request, A.token); + await addSocialAppContract(request, B.token); + + // Two pre-authed contexts (token cookies on social.localhost + + // auth.localhost), separate browser instances — two users, two browsers. + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + await setTokenCookie(contextA, 'social.localhost', A.token); + await setTokenCookie(contextA, 'auth.localhost', A.token); + await setTokenCookie(contextB, 'social.localhost', B.token); + await setTokenCookie(contextB, 'auth.localhost', B.token); + const pageA = await contextA.newPage(); + const pageB = await contextB.newPage(); + + const logsA = captureConsoleLogs(pageA, '[social-dms]'); + const logsB = captureConsoleLogs(pageB, '[social-dms]'); + const pageErrorsA: string[] = []; + pageA.on('pageerror', (e) => pageErrorsA.push(e.message)); + const pageErrorsB: string[] = []; + pageB.on('pageerror', (e) => pageErrorsB.push(e.message)); + + const firstMsg = `hello from A ${Date.now()}`; + const replyMsg = `reply from B ${Date.now()}`; + + // --- A loads /messages (pre-authed, no login popup) — empty state --- + await pageA.goto(`${SOCIAL_BASE}/messages`); + await pageA.waitForLoadState('networkidle'); + await expect(pageA.locator('[data-testid="dms-empty"]')).toBeVisible({ timeout: 20000 }); + + // --- A composes to B by username → the first send creates the group --- + await pageA.locator('[data-testid="dm-new-message-btn"]').click(); + await expect(pageA.locator('[data-testid="dm-contact-picker"]')).toBeVisible(); + await pageA.locator('[data-testid="dm-compose-username-btn"]').click(); + await pageA.locator('[data-testid="dm-compose-username"]').fill(B.username); + await pageA.locator('[data-testid="dm-compose-message"]').fill(firstMsg); + await pageA.locator('[data-testid="dm-compose-send"]').click(); + + // The conversation opens with A's message in the thread. + await expect(pageA.locator('[data-testid="dm-conversation"]')).toBeVisible({ timeout: 20000 }); + await expectThreadContains(pageA, firstMsg); + + // A's log sequence: no group yet → create (deterministic name) → sent. + const aFindNull = logsA.findIndex((l) => l.includes('findDmGroup') && l.includes('match: null')); + const aCreateIdx = logsA.findIndex((l) => l.includes('ensureDmGroup — no group yet, creating')); + const aCreatedIdx = logsA.findIndex((l) => l.includes('ensureDmGroup — created')); + const aSentIdx = logsA.findIndex((l) => l.includes('sendDm — sent')); + expect(aFindNull, 'A must look for an existing DM group first').toBeGreaterThanOrEqual(0); + expect(aCreateIdx, 'A must create the group (none existed)').toBeGreaterThanOrEqual(0); + expect(aCreatedIdx, 'A must log the created group').toBeGreaterThanOrEqual(0); + expect(aSentIdx, 'A must log the sent message').toBeGreaterThanOrEqual(0); + expect(aCreateIdx).toBeGreaterThan(aFindNull); + expect(aCreatedIdx).toBeGreaterThan(aCreateIdx); + expect(aSentIdx).toBeGreaterThan(aCreatedIdx); + const aGroupId = (logsA[aCreatedIdx].match(/created (\S+)$/) || [])[1]; + expect(aGroupId, 'A must create the group under the deterministic name').toBe( + `${PROVIDER}/groups/users/${A.username}/${dmGroupName(A.username, B.username)}`, + ); + + // --- B loads /messages → the conversation is in B's list --- + await pageB.goto(`${SOCIAL_BASE}/messages`); + await pageB.waitForLoadState('networkidle'); + await expect(pageB.locator('[data-testid="dm-conversation-item"]')).toBeVisible({ timeout: 20000 }); + // The list shows A as the other party, with A's message as the last one. + const itemText = await pageB.locator('[data-testid="dm-conversation-item"]').first().textContent(); + expect(itemText).toContain(A.username); + expect(itemText).toContain(firstMsg); + + // --- B opens the conversation → sees A's message (the receive) --- + await pageB.locator('[data-testid="dm-conversation-item"]').first().click(); + await expect(pageB.locator('[data-testid="dm-conversation"]')).toBeVisible({ timeout: 20000 }); + await expectThreadContains(pageB, firstMsg); + + // B found the SAME group A created (the deterministic name resolved to + // the same group_id on both sides). + const bFindIdx = logsB.findIndex((l) => l.includes('findDmGroup') && l.includes('match:')); + expect(bFindIdx, 'B must resolve the DM group by name').toBeGreaterThanOrEqual(0); + const bGroupId = (logsB[bFindIdx].match(/match: (\S+)$/) || [])[1]; + expect(bGroupId, 'B must resolve to the SAME group A created').toBe(aGroupId); + const bReadIdx = logsB.findIndex((l) => l.includes('readDms — got')); + expect(bReadIdx, 'B must read the thread').toBeGreaterThanOrEqual(0); + expect(bReadIdx).toBeGreaterThan(bFindIdx); + + // --- B replies (the group already exists — no create) --- + await pageB.locator('[data-testid="dm-input"]').fill(replyMsg); + await pageB.locator('[data-testid="dm-send-button"]').click(); + await expectThreadContains(pageB, replyMsg); + const bSentIdx = logsB.findIndex((l) => l.includes('sendDm — sent')); + expect(bSentIdx, 'B must log the reply').toBeGreaterThanOrEqual(0); + expect(bSentIdx).toBeGreaterThan(bReadIdx); + // B must NOT have created a second group. + expect(logsB.some((l) => l.includes('ensureDmGroup — no group yet, creating')), 'B must reuse the existing group').toBeFalsy(); + + // --- A reloads → sees B's reply (CRUD is the delivery path) --- + await pageA.reload(); + await pageA.waitForLoadState('networkidle'); + await expect(pageA.locator('[data-testid="dm-conversation"]')).toBeVisible({ timeout: 20000 }); + await expectThreadContains(pageA, firstMsg); + await expectThreadContains(pageA, replyMsg); + const aReReadIdx = logsA.findIndex((l) => l.includes('readDms — got 2 messages')); + expect(aReReadIdx, 'A must read both messages after the reload').toBeGreaterThanOrEqual(0); + expect(aReReadIdx).toBeGreaterThan(aSentIdx); + + // No errors in either console, no uncaught exceptions. + const errorsA = logsA.filter((l) => l.includes('FAILED') || l.includes('Error')); + const errorsB = logsB.filter((l) => l.includes('FAILED') || l.includes('Error')); + expect(errorsA).toEqual([]); + expect(errorsB).toEqual([]); + expect(pageErrorsA).toEqual([]); + expect(pageErrorsB).toEqual([]); + }); +}); diff --git a/knowledge/changelogs/CHANGELOG.md b/knowledge/changelogs/CHANGELOG.md index 53d8784e..e25651bf 100644 --- a/knowledge/changelogs/CHANGELOG.md +++ b/knowledge/changelogs/CHANGELOG.md @@ -1,9 +1,11 @@ +3.25.2 || 28.08.2026 +test(e2e) + fix(marketing): the social Messages surface e2e (social-e2e lane) — and the two DM bugs it caught. **The spec (`e2e/tests/social-messages.spec.ts`, 4 tests):** API floor pinning the app's exact DM pattern — the DM group is created with the deterministic NAME `dm-{sorted}` (invite_only, one `member` role, both participants as bare-username members), the API derives the group_id as `{provider}/groups/users/{creator}/{name}` (the creator is embedded in the ID — not symmetric), the recipient finds the group by name suffix in their own group list, messages are `posts` docs in the group (send → read-back → reply round-trip) + the deterministic-name no-duplicate test + the I3 anti-test (a third user's read of the DM group 403s "not a member"). Browser gauntlet: two pre-authed contexts (token cookies on social.localhost + auth.localhost) — A composes to B by username (the picker's compose path; the first send creates the group), B's context sees the conversation in the list (other party resolved from membership) and A's message in the thread, B replies (group reused, no second create), A's reload sees the reply; `[social-dms]` seam-log sequence asserted on both sides (find-null → create → sent; find-match → read → reply-sent; re-read 2), same group_id on both sides, no pageerror. **Bug 1 (the surface was broken end to end):** `dms.ts` derived the DM group as `web10.app/groups/{first}/dm-{second}` (the KB's well-known shape) and added members with provider-qualified keys (`web10.app/users/{username}`). Neither can exist: `POST /v3/groups/create` derives the group_id from the caller's token (`{provider}/groups/users/{creator}/{name}` — the well-known shape is only creatable by direct DB insert, like the boot-time discover group), and the node's user key is the BARE username, so the provider-qualified member keys never matched the real users — the recipient was never a member and every receive 403'd. Fix (in-lane, `src/data/dms.ts`): the deterministic identifier is the group NAME `dm-{sorted}` (the `messages-demo` findDmGroup reference pattern); both parties find the group by name suffix in their own group list; member keys are bare usernames; `listConversations` resolves the other party from membership (the name alone is ambiguous — usernames may contain dashes) with a `member_count === 2` guard; `readDms` on a not-yet-existing conversation returns `[]` instead of 403. **Bug 2 (isMe never matched):** `sender_provider` derived from a bare `author_key` is the literal string `web10` (`extractProvider`'s fallback), never the node's provider, so the provider-qualified isMe comparison never matched in ANY environment — own messages rendered as the other party's (left-aligned, no edit/delete menu). Now username-based (v3 DMs are same-node): DmsScreen bubble, MailView thread + formatFromTo, CrmView, and `classifyThread`/`replyAllTargets` in dms.ts. **Out of lane (`.context/social-e2e-messages-handoff.md`):** `groups.ts` still exports the broken `dmGroupId`/`ensureDmGroup`, and the SAME well-known-shape mismatch affects `followersGroupId`/`closeFriendsGroupId`/`ensureCommunity` (the feed/groups/profile lanes will hit it); KB drift — `db/clickhouse.md`'s `user_blacklist`/`group_blacklist` DDL is stale (code expects `updated_at`+`deleted`, a fresh CH built from the KB DDL 500s on every group read) and `social-contracts.md`'s DM Group ID column is uncreatable via the API. Screenshots re-captured (chat/mail/crm/settings, desktop + 375px) — green run per the temporary no-PNG-read override. 197 web10-social unit tests green, `tsc -b && vite build` clean, spec tsc-clean (the pre-existing exporter/gauntlet/messages-demo tsc errors are untouched). + 3.25.1 || 28.08.2026 fix(marketing-social) + test(e2e): the social app's follows surface works — the follow button actually follows — and the groups e2e floor + gauntlet land (social-e2e lane, the M0 machine track). **The bug:** the app's `followersGroupId` returned `web10.app/groups/{username}/followers` — the wrong shape (missing the `users/` segment) and a hardcoded provider (`web10.app`) instead of the node's. The API derives a created group's ID from the token's `provider` claim as `{provider}/groups/users/{creator}/{slug}`, so the app was addressing a group that never exists: the follow button's `joinGroup` 404'd and silently reverted to "Follow", and `ensureFollowers` created the group under a doubled-username slug + a full-key member_key (`web10.app/users/{username}`) the membership checks never match (joins + discover auto-enrollment store the bare username). **The fix (`src/data/groups.ts` + `follows.ts`):** `followersGroupId(username, provider?)` now returns `{provider}/groups/users/{username}/followers` — the `provider` is the token's (the source of truth the API embeds), falling back to `API_HOST` (the two always agree — the token's provider is the API's own hostname). `ensureFollowers` creates under the bare slug `followers` (the API embeds the creator) with the bare username as the owner member_key. `isFollowing` now checks `getMyGroups()` membership — the API's `groups/get` does not return `my_role`, so the old per-group check was always false. The follow button (`UserProfileScreen.handleFollow`) gets `[social]` logging at each transition. **The e2e (`e2e/tests/social-groups.spec.ts`):** the API floor pins the app's exact feed read (getMyGroups minus discover → one multi-group posts read) + the follow/unfollow group ops + the feed-read delta (follow → the creator's post enters the feed; unfollow → it's gone, 403 on a direct read) + the I3 anti-test (a stranger who never followed cannot read the followers group). The browser gauntlet: viewer pre-authed via the token cookie (social.localhost + auth.localhost) → open the creator's `/u/:username` → follow (the real button) → the post appears in `/feed` → unfollow → it leaves, with console log-sequence verification. Group *management* (create/roles/invite) stays the authenticator + marketing-directory surface — its floors live in `groups-demo`. **Tests:** 3 e2e green (2 API floor + 1 browser gauntlet, stable across runs), 200 web10-social unit tests green (`follows.test.ts` expectations re-aligned to the new ID pattern), `tsc -b` + `vite build` clean, screenshots verified desktop + 375px. 3.25.0 || 27.08.2026 test(api): the tagged-post ad conformance floor (D55) — the ads lane's foundation. An ad is a `posts` doc tagged `ad` (a leaf-typed `offer` + a `status`), not a service — so the API has zero ad-specific branches, and the conformance is that the ad post is indistinguishable from a post except its tag + body fields. Pinned by `api/tests/test_ads.py` (5 tests): (1) the ad post is created through the existing `/v3/create` on `posts` (no `ads` service, no new endpoint) with `tags=["ad"]` + the leaf-typed `offer` + `status` in the body, attached to the creator's followers group; (2) the feed read (`read_documents_in_groups` over the followers group) returns it interleaved with normal posts — same shape, same keys, the ad fields are the only delta; (3) I3 — a non-follower is an access failure (D42 403) and the document query never runs, so the ad is never returned; (4) `status` is a plain body field the read does NOT filter — a paused ad comes back exactly like an active one (curation + the renderer filter client-side, D51). Plus a SQL-level pin: the feed read query selects the `tags` column but filters on neither tags nor status (`status` is a body field, not a column — it cannot be filtered in SQL at all). No production code changed — the API already treats ad posts as posts (verified: no `offer` / `ad`-tag branches in `api/app/v3/`). 795 API tests green, ruff clean. Gates the catalog + composer (both read this). - 3.24.0 || 28.08.2026 feature(api) + feature(ui) + test(e2e) + docs(kb): groups default to NOT discoverable (D53 amendment) + the authenticator's group contract editors work and are torture-tested. Operator, on the discover tab: "in the group discover i am seeing all the groups. i think groups sshould default not discover, in these demo apps like notes etc, messages, where it is a backend thing for an app, why should that be in the discover tab" — then: "also i want the contract policy editors to work and be torture tested, and to make groups discoverable toggle stuff like that, talking about the authenticator." **The default flip (D53 amendment):** `create_group` now defaults `discoverable` to `False` for every join policy — listing is an opt-in, not a default. App-backend groups (notes, messages, DMs) are infrastructure the apps create on the user's behalf, not communities meant to be browsed, so they stay out of the directory unless an owner deliberately blasts them; the `invite_only` special-case is subsumed (all groups default to `false`). The DDL default flips to `0` (template + boot `ALTER`). The node stays readable-by-design (D41): the detail (by-ID read) is still open to any principal (unlisted-model) — only the *browse* surface is opt-in. **The backfill (one-time, sentinel-gated):** groups created under the earlier discoverable-by-default rule carry `discoverable = 1`; a boot migration (`_migrate_discoverable_default_flip`) delists them (appends a `discoverable = 0` row per live listed group) so the directory reflects the opt-in model. It runs exactly once (a `node_config` sentinel `migration:discoverable_default_flip` marks completion) and only ever moves groups OUT of the directory (membership / content access untouched); safe under concurrent gunicorn workers (duplicate rows dedup to one). **The contract policy editors (authenticator):** the "Settings" button was a TODO ("Join policy editor coming soon") — now a real `GroupSettingsDialog` join-policy editor (Open / Request / Invite-only picker → `v3UpdateGroup({join_policy})`). The roles editor (`GroupRolesDialog`) and the "List in directory" (`discoverable`) toggle already existed and are now verified end-to-end. All three contract controls (roles, join_policy, discoverable) work through the real UI → `POST /v3/groups/update` → persisted. **Bug fix (exposed by the torture tests):** `groupDisplayName` returned `users/` (parts[2]/parts[3]) instead of the slug — so every group a user owned showed the same name; it now returns the slug (the last path segment). **Torture tests (new `e2e/tests/group-contract-editors.spec.ts`, 11 tests):** API floor (join_policy / roles / discoverable update persists; I3 anti-test — a non-member's update is rejected, the API maps a permission denial to the `CRUD` 401) + browser gauntlet (real UI: join-policy change → persisted + card badge updates; cancel fork → no change; save-failure fork → status-bar error, no crash, dialog stays open; roles add → persisted; empty-role-name anti-test → blocked, nothing saved; discoverable toggle ON → listed in the anon directory; toggle OFF → delisted). Every browser test asserts no pageerror (diagnostic dump). **KB:** D53 amended in `decisions.md` (default `false`, the operator's amendment quoted) + `groups/discoverability.md` (opt-in model + backfill note). **Tests:** 3 new API unit tests (backfill: skip-when-sentinel, delist+set-sentinel, delist-query-filters-discoverable-1) + the `TestCreateGroup` defaults re-aligned (not-discoverable for every join policy) + 5 new UI unit tests (GroupSettingsDialog: current-policy-selected, pick+save, save-failure, cancel; GroupCard: undefined→not-listed) + the 11 e2e torture tests. 789 API + 105 UI green, ruff + `tsc -b` clean, existing `groups-demo.spec.ts` (30) green (no regression). diff --git a/knowledge/strategy/parallel-execution.md b/knowledge/strategy/parallel-execution.md index c63bb9f8..7434087b 100644 --- a/knowledge/strategy/parallel-execution.md +++ b/knowledge/strategy/parallel-execution.md @@ -223,7 +223,7 @@ exact counts (the `feed-demo` pattern). - [ ] Feed (`e2e/tests/social-feed.spec.ts`) — API floor: the app's exact feed read (discover + followers multi-group, sort config) + I3 (a non-follower's group post is absent). Browser gauntlet: real D42 login → feed renders → post → reload persists. - [✓ 3.25.1] Groups (`e2e/tests/social-groups.spec.ts`) — the app's groups surface: follows (followers groups) as the app drives them — follow → the creator's posts enter the feed → unfollow → they leave. API floor: the follow/unfollow group ops + the feed-read delta. Browser gauntlet: follow/unfollow through the app, feed reflects it. (Group *management* — create/roles/invite — is the authenticator + marketing directory surface; its floors live in `groups-demo`.) - [ ] Profiles (`e2e/tests/social-profile.spec.ts`) — API floor: profile doc + posts read + follower count; I3 (a stranger's private data is not readable). Browser gauntlet: own profile (edit persists) + another user's public profile + the `/u/:username/p/:postId` deep link. -- [ ] Messages (`e2e/tests/social-messages.spec.ts`) — API floor: DM group contract + CRUD (deterministic DM group ID). Browser gauntlet: two-user DM round-trip through the app (send → receive → reply). +- [✓ 3.25.2] Messages (`e2e/tests/social-messages.spec.ts`) — API floor: DM group contract + CRUD (deterministic DM group ID). Browser gauntlet: two-user DM round-trip through the app (send → receive → reply). - [ ] Settings (`e2e/tests/social-settings.spec.ts`) — API floor: settings doc read/write round-trip. Browser gauntlet: change a setting → persists across reload + sign-out/sign-in. - [ ] Trending (`e2e/tests/social-trending.spec.ts`) — the `/discover` board surface (the in-app trending: D36 knobs over the node-default discover group). API floor: anon board read + engagement counts. Browser gauntlet: the board renders seeded posts, the knobs re-rank, deep-linkable state. (Complements the `discover-board` lane's board gauntlet — that one owns the moderation ops: seed → anon read → hide/restore round-trip.) - [ ] Capstone gauntlet (`e2e/tests/social-gauntlet.spec.ts`) — one journey across all screens (login → feed → post → profile → DM → follow → settings → reload), log-sequence verified. **Gated on all six surface specs above.** diff --git a/marketing/web10-social/screenshots/chat-375.png b/marketing/web10-social/screenshots/chat-375.png index ec98986c..eac0d9f0 100644 Binary files a/marketing/web10-social/screenshots/chat-375.png and b/marketing/web10-social/screenshots/chat-375.png differ diff --git a/marketing/web10-social/screenshots/chat-desktop.png b/marketing/web10-social/screenshots/chat-desktop.png index c9759542..07981380 100644 Binary files a/marketing/web10-social/screenshots/chat-desktop.png and b/marketing/web10-social/screenshots/chat-desktop.png differ diff --git a/marketing/web10-social/screenshots/crm-375.png b/marketing/web10-social/screenshots/crm-375.png index 3a246fe6..014c2fe0 100644 Binary files a/marketing/web10-social/screenshots/crm-375.png and b/marketing/web10-social/screenshots/crm-375.png differ diff --git a/marketing/web10-social/screenshots/crm-desktop.png b/marketing/web10-social/screenshots/crm-desktop.png index 3898ce2c..0c3587f2 100644 Binary files a/marketing/web10-social/screenshots/crm-desktop.png and b/marketing/web10-social/screenshots/crm-desktop.png differ diff --git a/marketing/web10-social/screenshots/mail-375.png b/marketing/web10-social/screenshots/mail-375.png index 94797185..3f9b46d4 100644 Binary files a/marketing/web10-social/screenshots/mail-375.png and b/marketing/web10-social/screenshots/mail-375.png differ diff --git a/marketing/web10-social/screenshots/mail-desktop.png b/marketing/web10-social/screenshots/mail-desktop.png index 5db745c5..5c3b0658 100644 Binary files a/marketing/web10-social/screenshots/mail-desktop.png and b/marketing/web10-social/screenshots/mail-desktop.png differ diff --git a/marketing/web10-social/screenshots/settings-375.png b/marketing/web10-social/screenshots/settings-375.png index af12901f..9a1856e6 100644 Binary files a/marketing/web10-social/screenshots/settings-375.png and b/marketing/web10-social/screenshots/settings-375.png differ diff --git a/marketing/web10-social/screenshots/settings-desktop.png b/marketing/web10-social/screenshots/settings-desktop.png index 37893c73..00c85cd4 100644 Binary files a/marketing/web10-social/screenshots/settings-desktop.png and b/marketing/web10-social/screenshots/settings-desktop.png differ diff --git a/marketing/web10-social/src/components/Chat/CrmView.tsx b/marketing/web10-social/src/components/Chat/CrmView.tsx index 2c6ace8d..2de9fadd 100644 --- a/marketing/web10-social/src/components/Chat/CrmView.tsx +++ b/marketing/web10-social/src/components/Chat/CrmView.tsx @@ -692,8 +692,12 @@ function ContactDetail({
{messages.map((msg) => { const token = getWapi().readToken(); + // Username-based: v3 DMs are same-node (bare-username member + // keys), and the sender_provider derived from a bare + // author_key is not the node's provider, so a + // provider-qualified comparison never matches. const isMe = token - ? `${token.provider}/${token.username}` === `${msg.sender_provider}/${msg.sender_username}` + ? msg.sender_username === token.username : false; return (
+
void; }) { const token = getWapi().readToken(); - const myKey = token ? `${token.provider}/${token.username}` : ''; const myUsername = token?.username || ''; const [input, setInput] = useState(''); const [subject, setSubject] = useState(''); @@ -218,9 +217,10 @@ function ThreadDetail({ const threadSubject = thread.messages.find((m) => m.subject)?.subject || ''; function formatFromTo(msg: DmRecord) { - const senderKey = `${msg.sender_provider}/${msg.sender_username}`; - const recipientKey = `${msg.recipient_provider}/${msg.recipient_username}`; - const isMe = myKey === senderKey; + // Username-based: v3 DMs are same-node (bare-username member keys), and + // the sender_provider derived from a bare author_key is not the node's + // provider, so a provider-qualified comparison never matches. + const isMe = msg.sender_username === myUsername; const fromName = isMe ? myUsername : msg.sender_username; const toName = isMe ? msg.recipient_username : myUsername; return { fromName, toName, isMe }; @@ -409,7 +409,7 @@ function ThreadDetail({
) : ( thread.messages.map((msg) => { - const isMe = myKey === `${msg.sender_provider}/${msg.sender_username}`; + const isMe = msg.sender_username === myUsername; const { fromName, toName } = formatFromTo(msg); return (
{ + const w = getV3Client(); + const suffix = `/${dmGroupName(me, other)}`; + const groups = await w.getMyGroups(); + const match = groups.find((g) => g.group_id.endsWith(suffix)); + console.log( + '[social-dms] findDmGroup — me:', me, + 'other:', other, 'suffix:', suffix, + 'match:', match ? match.group_id : null, + ); + return match ? match.group_id : null; +} + +/** + * Ensure the DM group between me and other exists. Returns the group_id. + * Finds an existing group by the deterministic name (either party may have + * created it); creates it (invite_only, both as members) when absent. + */ +async function ensureDmGroup(me: string, other: string): Promise { + const existing = await findDmGroup(me, other); + if (existing) return existing; + const w = getV3Client(); + const name = dmGroupName(me, other); + console.log('[social-dms] ensureDmGroup — no group yet, creating', name); + const res = await w.createGroup(name, 'invite_only', DM_ROLES, [ + { member_key: me, role: 'member' }, + { member_key: other, role: 'member' }, + ]); + console.log('[social-dms] ensureDmGroup — created', res.group_id); + return res.group_id; +} /** * Derive a deterministic conversation key for a pair of users. @@ -32,8 +97,15 @@ export async function readDms(conversation: string): Promise { const themKey = parts.find((p) => p !== meKey) || parts[0]; const [, otherUsername] = themKey.split('/'); - const groupId = dmGroupId(token.username, otherUsername); + const groupId = await findDmGroup(token.username, otherUsername); + if (!groupId) { + // No DM group yet (nothing sent either way) — an empty conversation, + // not an error (a group read by a non-member would 403). + console.log('[social-dms] readDms — no DM group yet for', otherUsername, '— empty'); + return []; + } const docs = await w.read('posts', { groups: [groupId] }); + console.log('[social-dms] readDms — got', docs.length, 'messages from', groupId); return docs.map(fromV3DocToDm).sort( (a, b) => new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime(), ); @@ -70,6 +142,7 @@ export async function sendDm( if (opts?.subject) body.subject = opts.subject; const doc = await w.create('posts', body, { groups: [groupId] }); + console.log('[social-dms] sendDm — sent', doc.doc_id, 'in', groupId); return fromV3DocToDm(doc); } @@ -92,7 +165,10 @@ export async function updateDm(id: string, message: string): Promise { /** * List all conversations the current user participates in. - * DM groups are groups with names containing 'dm-'. + * DM groups are the 2-member groups whose slug is the deterministic name + * dm-{first}-{second}. The creator is embedded in the group_id, so the other + * party is resolved from membership (the name alone is ambiguous — usernames + * may contain dashes). */ export async function listConversations(): Promise { const w = getV3Client(); @@ -103,20 +179,19 @@ export async function listConversations(): Promise { const conversations = new Set(); for (const g of groups) { - if (g.group_id.includes('/dm-')) { - // Extract the other user's username from the group ID - const match = g.group_id.match(/dm-(.+)$/); - if (match) { - const otherUsername = match[1]; - const otherProvider = g.group_id.split('/')[0] || 'web10'; - conversations.add(conversationKey( - { provider: token.provider, username: token.username }, - { provider: otherProvider, username: otherUsername }, - )); - } - } + const slug = g.group_id.split('/').pop() || ''; + if (!slug.startsWith('dm-') || g.member_count !== 2) continue; + const members = await w.getGroupMembers(g.group_id); + const other = members.find((m) => m.member_key !== token.username); + if (!other) continue; + const otherProvider = g.group_id.split('/')[0] || token.provider; + conversations.add(conversationKey( + { provider: token.provider, username: token.username }, + { provider: otherProvider, username: other.member_key }, + )); } + console.log('[social-dms] listConversations —', conversations.size, 'conversations'); return [...conversations]; } @@ -168,9 +243,11 @@ export function classifyThread( _otherSpamFlagged: boolean, ): DmFolder { if (!lastMsg) return 'inbox'; - const senderKey = `${lastMsg.sender_provider}/${lastMsg.sender_username}`; - const meKey = `${me.provider}/${me.username}`; - return senderKey === meKey ? 'sent' : 'inbox'; + // Compare by username: v3 DMs are same-node (member keys are bare + // usernames), and the sender_provider derived from a bare author_key is + // not the node's provider, so a provider-qualified comparison never + // matches. + return lastMsg.sender_username === me.username ? 'sent' : 'inbox'; } // ── Backward compat ────────────────────────────────────────────────────────── @@ -224,9 +301,12 @@ export function replyAllTargets( if (msg.cc) msg.cc.forEach(add); if (!msg.to?.length) { - const senderKey = `${msg.sender_provider}/${msg.sender_username}`; - const recipientKey = `${msg.recipient_provider}/${msg.recipient_username}`; - const otherKey = senderKey === meKey ? recipientKey : senderKey; + // Username-based: the sender_provider on a v3 doc is derived from the + // bare author_key, so a provider-qualified comparison never matches. + const isSenderMe = msg.sender_username === me.username; + const otherKey = isSenderMe + ? `${msg.recipient_provider}/${msg.recipient_username}` + : `${msg.sender_provider}/${msg.sender_username}`; const [provider, username] = otherKey.split('/'); add({ username, provider }); }