auth: train line comes from the room's pinned notice; drop browser-side acceptance
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

The collector reads the notice version from the room's pinned notice and takes
the member's first reply as the acceptance (Heimdall answer-collector-consent.md),
so a localStorage record at registration was a second, unread copy. Deleted it
and its unused re-ask helpers. RoomTrainingLine now reads the room's own pinned
notice and shows the train sentence only where that notice declares train; no
notice, no line (DMs included). The pinned event's encoding is not fixed by the
doc; the key is provisional and isolated in trainingNotice.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-06 22:27:47 +00:00
commit 3fcf1cf9dd
5 changed files with 80 additions and 75 deletions

View file

@ -1,15 +1,50 @@
import { Text, color, config } from 'folds';
import React from 'react';
import { TRAINING_NOTICE_TEXT } from '../../utils/trainingNotice';
import { Room } from 'matrix-js-sdk';
import React, { useEffect, useState } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomNotice, parseRoomNotice, roomTrainingLine } from '../../utils/trainingNotice';
// One line per room, pinned above the composer.
export function RoomTrainingLine() {
// The room's own pinned notice decides the line, newest pin first; no notice, no line. The version
// is read by the collector from the same pinned event, so nothing is stored in the browser.
const useRoomNotice = (room: Room): RoomNotice | undefined => {
const mx = useMatrixClient();
const pinned = useRoomPinnedEvents(room);
const [notice, setNotice] = useState<RoomNotice>();
useEffect(() => {
let live = true;
setNotice(undefined);
(async () => {
for (let i = pinned.length - 1; i >= 0; i -= 1) {
const id = pinned[i];
const local = room.findEventById(id)?.getContent();
// eslint-disable-next-line no-await-in-loop
const content = local ?? (await mx.fetchRoomEvent(room.roomId, id).catch(() => undefined))?.content;
const found = parseRoomNotice(content);
if (found) {
if (live) setNotice(found);
return;
}
}
})();
return () => {
live = false;
};
}, [mx, room, pinned]);
return notice;
};
export function RoomTrainingLine({ room }: { room: Room }) {
const line = roomTrainingLine(useRoomNotice(room));
if (!line) return null;
return (
<Text
size="T200"
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
>
{TRAINING_NOTICE_TEXT}
{line}
</Text>
);
}

View file

@ -105,7 +105,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomTrainingLine />
<RoomTrainingLine room={room} />
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}

View file

@ -33,7 +33,6 @@ import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import { recordNoticeAcceptance } from '../../../utils/trainingNotice';
import {
AutoDummyStageDialog,
AutoTermsStageDialog,
@ -243,7 +242,6 @@ export function PasswordRegisterForm({
terms,
clientSecret: mx.generateClientSecret(),
};
recordNoticeAcceptance(localStorage);
const pickedStages = pickStages(uiaFlows, fData);
const pickedFlow = getUIAFlowForStages(uiaFlows, pickedStages);
setOngoingFlow(pickedFlow);

View file

@ -1,19 +1,12 @@
import { describe, expect, it } from 'vitest';
import {
ROOM_NOTICE_CONTENT_KEY,
TRAINING_NOTICE_TEXT,
TRAINING_NOTICE_VERSION,
getNoticeAcceptance,
needsFreshAcceptance,
recordNoticeAcceptance,
parseRoomNotice,
roomTrainingLine,
} from './trainingNotice';
const memoryStorage = () => {
const data = new Map<string, string>();
return {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
};
};
const pinned = (notice: unknown) => ({ body: 'sample', [ROOM_NOTICE_CONTENT_KEY]: notice });
describe('training notice', () => {
it('says exactly the ruled sentence and never mentions Discord', () => {
@ -23,29 +16,19 @@ describe('training notice', () => {
expect(TRAINING_NOTICE_TEXT.toLowerCase()).not.toContain('discord');
});
it('records the current version and time on acceptance', () => {
const storage = memoryStorage();
recordNoticeAcceptance(storage, new Date('2026-11-06T20:00:00Z'));
expect(getNoticeAcceptance(storage)).toEqual({
noticeVersion: TRAINING_NOTICE_VERSION,
acceptedAt: '2026-11-06T20:00:00.000Z',
});
expect(needsFreshAcceptance(storage)).toBe(false);
it('shows the train line only where the room declares train', () => {
const train = parseRoomNotice(pinned({ version: 2, scope: 'train' }));
expect(train).toEqual({ version: 2, scope: 'train' });
expect(roomTrainingLine(train)).toBe(TRAINING_NOTICE_TEXT);
expect(roomTrainingLine(parseRoomNotice(pinned({ version: 1, scope: 'study' })))).toBeUndefined();
expect(roomTrainingLine(parseRoomNotice(pinned({ version: 1, scope: 'show' })))).toBeUndefined();
});
it('asks again when the notice version changes', () => {
const storage = memoryStorage();
recordNoticeAcceptance(storage, new Date(), TRAINING_NOTICE_VERSION);
expect(needsFreshAcceptance(storage, TRAINING_NOTICE_VERSION + 1)).toBe(true);
});
it('does not re-ask members who never registered here (no record)', () => {
expect(needsFreshAcceptance(memoryStorage())).toBe(false);
});
it('treats a corrupt record as none', () => {
const storage = memoryStorage();
storage.setItem('trainingNoticeAcceptance', '{nope');
expect(getNoticeAcceptance(storage)).toBeUndefined();
it('shows nothing where no notice is declared, and never infers train', () => {
expect(roomTrainingLine(undefined)).toBeUndefined();
expect(parseRoomNotice({ body: 'plain message' })).toBeUndefined();
expect(parseRoomNotice(pinned({ scope: 'train' }))).toBeUndefined();
expect(parseRoomNotice(pinned({ version: 0, scope: 'train' }))).toBeUndefined();
expect(parseRoomNotice(pinned({ version: 1, scope: 'everything' }))).toBeUndefined();
});
});

View file

@ -1,43 +1,32 @@
export const TRAINING_NOTICE_TEXT =
"Everything said here may be used to train GameCult's AI stewards.";
// Any change to the wording or scope is a new version, and a new version needs a fresh acceptance.
export const TRAINING_NOTICE_VERSION = 1;
/**
* Where a room declares its notice. Heimdall's answer-collector-consent.md says the notice is pinned
* with the sample and carries `notice_version` and a scope, but does not fix the pinned event's
* encoding; this key is this client's provisional reading of it, and the one place to change.
*/
export const ROOM_NOTICE_CONTENT_KEY = 'org.gamecult.notice';
const ACCEPTANCE_KEY = 'trainingNoticeAcceptance';
export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' };
export type NoticeAcceptance = { noticeVersion: number; acceptedAt: string };
type NoticeStorage = Pick<Storage, 'getItem' | 'setItem'>;
/** Written at registration only, so only newcomers have one; earlier members are never re-asked. */
export const recordNoticeAcceptance = (
storage: NoticeStorage,
now: Date = new Date(),
noticeVersion: number = TRAINING_NOTICE_VERSION
): NoticeAcceptance => {
const acceptance = { noticeVersion, acceptedAt: now.toISOString() };
storage.setItem(ACCEPTANCE_KEY, JSON.stringify(acceptance));
return acceptance;
};
export const getNoticeAcceptance = (storage: NoticeStorage): NoticeAcceptance | undefined => {
try {
const parsed = JSON.parse(storage.getItem(ACCEPTANCE_KEY) ?? 'null');
if (typeof parsed?.noticeVersion === 'number' && typeof parsed?.acceptedAt === 'string') {
return parsed;
}
} catch {
// unreadable record counts as none
/** The notice a pinned event declares, or undefined: no scope is ever inferred. */
export const parseRoomNotice = (content: unknown): RoomNotice | undefined => {
const raw = (content as Record<string, unknown> | undefined)?.[ROOM_NOTICE_CONTENT_KEY] as
| { version?: unknown; scope?: unknown }
| undefined;
if (
raw &&
typeof raw.version === 'number' &&
Number.isInteger(raw.version) &&
raw.version > 0 &&
(raw.scope === 'show' || raw.scope === 'study' || raw.scope === 'train')
) {
return { version: raw.version, scope: raw.scope };
}
return undefined;
};
/** True when a newcomer's recorded acceptance is for an older notice version and must be renewed. */
export const needsFreshAcceptance = (
storage: NoticeStorage,
currentVersion: number = TRAINING_NOTICE_VERSION
): boolean => {
const accepted = getNoticeAcceptance(storage);
return accepted !== undefined && accepted.noticeVersion !== currentVersion;
};
/** The line a room shows: only a notice that says `train` in terms gets the train sentence. */
export const roomTrainingLine = (notice: RoomNotice | undefined): string | undefined =>
notice?.scope === 'train' ? TRAINING_NOTICE_TEXT : undefined;