auth: train line comes from the room's pinned notice; drop browser-side acceptance
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:
parent
e38ebda1c5
commit
3fcf1cf9dd
5 changed files with 80 additions and 75 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue