Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
17f6c5b
PROJ-1285 polish web-next homepage
AndrewNordstrom Jul 9, 2026
55ad233
[PROJ-1285] address homepage review feedback
AndrewNordstrom Jul 9, 2026
58df062
PROJ-1285 polish landing and how-it-works
AndrewNordstrom Jul 9, 2026
786c25f
[PROJ-1285] Ship product UI and guided shadow demo
AndrewNordstrom Jul 10, 2026
4d1aaf8
[PROJ-1285] Resolve governance UI review findings
AndrewNordstrom Jul 10, 2026
d049bc9
[PROJ-1285] Harden shadow demo client boundaries
AndrewNordstrom Jul 10, 2026
56197c1
[PROJ-1285] Close product demo review gaps
AndrewNordstrom Jul 10, 2026
33cdabc
[PROJ-1285] Wire reviewer demo to shadow governance v3
AndrewNordstrom Jul 11, 2026
70ecaa7
[PROJ-1285] Preserve final UI across backend rebase
AndrewNordstrom Jul 11, 2026
ec95d30
[PROJ-1285] Harden the reviewer demo client
AndrewNordstrom Jul 11, 2026
488febe
[PROJ-1285] Close reviewer demo release findings
AndrewNordstrom Jul 11, 2026
f699d55
[PROJ-1285] Bridge demo session rollout compatibility
AndrewNordstrom Jul 11, 2026
821ff4e
[PROJ-1285] Keep demo receipts and retries exact
AndrewNordstrom Jul 11, 2026
cbebfe1
[PROJ-1285] Close final product accessibility gaps
AndrewNordstrom Jul 11, 2026
3127a10
[PROJ-1285] Resolve final reviewer findings
AndrewNordstrom Jul 11, 2026
66ec568
[PROJ-1285] Tighten final review guards
AndrewNordstrom Jul 11, 2026
454e34f
[PROJ-1285] Scope copy safety checks precisely
AndrewNordstrom Jul 11, 2026
92c1308
[PROJ-1285] Make review guards semantic
AndrewNordstrom Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).

### Added
- Public, rate-limited `/api/demo/*` shadow-governance sessions with production-sourced score components, deterministic synthetic voters, isolated Redis state, reranked feeds, and inspectable receipts.
- Product-grade `web-next` landing, how-it-works, and no-login shadow-demo flows with shared Corgi/Bluesky presentation patterns, responsive product pages, and inspectable multi-epoch ranking receipts.
- Topic scoring engine: winkNLP-based topic classification at ingestion time
- Community topic weight voting (boost/penalize topics via governance)
- Bluesky content label filtering (replaces keyword-based NSFW exclusion)
Expand Down
7 changes: 6 additions & 1 deletion src/demo/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const TopicIntentSchema = z.object({

const CreateSessionBodySchema = z.object({
communityId: z.enum(SHADOW_DEMO_COMMUNITY_IDS).optional(),
clientNonce: IdempotencyKeySchema,
}).strict();

const SessionParamsSchema = z.object({
Expand Down Expand Up @@ -96,6 +97,7 @@ export function registerShadowDemoRoutes(
const body = parseOrThrow(CreateSessionBodySchema, request.body ?? {});
return service.createSession({
communityId: body.communityId ?? 'open_science_builders',
clientNonce: body.clientNonce,
});
});
});
Expand Down Expand Up @@ -202,7 +204,10 @@ function parseOrThrow<TSchema extends z.ZodTypeAny>(
): z.infer<TSchema> {
const parsed = schema.safeParse(value);
if (!parsed.success) {
throw new DemoValidationError(parsed.error.issues.map((issue) => issue.message).join('; '));
throw new DemoValidationError(parsed.error.issues.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join('.') : 'request';
return `${path}: ${issue.message}`;
}).join('; '));
}
return parsed.data;
}
Expand Down
27 changes: 26 additions & 1 deletion src/demo/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface ShadowDemoServiceDependencies {

export interface CreateSessionRequest {
communityId: ShadowDemoCommunityId;
clientNonce: string;
}

export interface CastVoteRequest {
Expand Down Expand Up @@ -135,7 +136,26 @@ export class ShadowDemoService {
this.now = dependencies.now;
}

private async replayCreatedSession(
clientNonce: string,
communityId: ShadowDemoCommunityId
): Promise<ShadowDemoServiceResult<ShadowDemoSessionPayload> | null> {
const existingSessionId = await this.store.readSessionIdByClientNonce(clientNonce);
if (!existingSessionId) {
return null;
}
const replay = await this.getSession(existingSessionId);
if (replay.payload.session.community.id !== communityId) {
throw new DemoConflictError('Session creation nonce was already used for a different community');
}
return replay;
}

async createSession(request: CreateSessionRequest): Promise<ShadowDemoServiceResult<ShadowDemoSessionPayload>> {
const existing = await this.replayCreatedSession(request.clientNonce, request.communityId);
if (existing) {
return existing;
}
const now = this.now();
const corpus = await this.loadCorpusForSession({
communityId: request.communityId,
Expand Down Expand Up @@ -173,9 +193,14 @@ export class ShadowDemoService {
const created = await this.store.createSession(
state,
ttlSecondsForState(state, now),
DEMO_MAX_ACTIVE_SESSIONS
DEMO_MAX_ACTIVE_SESSIONS,
request.clientNonce
);
if (!created) {
const replay = await this.replayCreatedSession(request.clientNonce, request.communityId);
if (replay) {
return replay;
}
throw new DemoStoreCapacityError(
`Shadow demo is at its ${DEMO_MAX_ACTIVE_SESSIONS}-session capacity; retry after an active session expires`
);
Expand Down
49 changes: 44 additions & 5 deletions src/demo/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,13 @@ export interface DemoSessionMutation<TPayload> {

export interface DemoStore {
readSession(sessionId: string): Promise<ShadowDemoSessionState | null>;
createSession(session: ShadowDemoSessionState, ttlSeconds: number, maxActiveSessions: number): Promise<boolean>;
readSessionIdByClientNonce(clientNonce: string): Promise<string | null>;
createSession(
session: ShadowDemoSessionState,
ttlSeconds: number,
maxActiveSessions: number,
clientNonce: string
): Promise<boolean>;
commitSessionMutation<TPayload>(mutation: DemoSessionMutation<TPayload>): Promise<boolean>;
readIdempotency<TPayload>(sessionId: string, key: string): Promise<IdempotencyRecord<TPayload> | null>;
readSharedCorpus(communityId: ShadowDemoCommunityId): Promise<ShadowDemoCorpus | null>;
Expand Down Expand Up @@ -243,10 +249,17 @@ export class RedisDemoStore implements DemoStore {
return { ...header, corpus };
}

async readSessionIdByClientNonce(clientNonce: string): Promise<string | null> {
return this.runRedisCommand('read session creation nonce', () =>
this.redis.get(sessionNonceKey(clientNonce))
);
}

async createSession(
session: ShadowDemoSessionState,
ttlSeconds: number,
maxActiveSessions: number
maxActiveSessions: number,
clientNonce: string
): Promise<boolean> {
const serialized = serializeSession(session);
const reserved = await this.reserveSessionSlot(session, maxActiveSessions);
Expand All @@ -263,19 +276,22 @@ export class RedisDemoStore implements DemoStore {
{ key: stagedCorpusKey, value: serialized.corpus },
]);
result = await this.runRedisCommand('create bounded session', () => this.redis.eval(
`if redis.call('zscore', KEYS[5], ARGV[2]) == false then return -1 end
`if redis.call('exists', KEYS[6]) == 1 then return 2 end
if redis.call('zscore', KEYS[5], ARGV[2]) == false then return -1 end
if redis.call('exists', KEYS[1]) == 0 or redis.call('exists', KEYS[2]) == 0 then return -1 end
redis.call('rename', KEYS[1], KEYS[3])
redis.call('rename', KEYS[2], KEYS[4])
redis.call('expire', KEYS[3], ARGV[1])
redis.call('expire', KEYS[4], ARGV[1])
redis.call('setex', KEYS[6], ARGV[1], ARGV[2])
return 1`,
5,
6,
stagedHeaderKey,
stagedCorpusKey,
sessionKey(session.sessionId),
corpusKey(session.corpusId),
activeSessionsKey(),
sessionNonceKey(clientNonce),
ttlSeconds,
session.sessionId
));
Expand Down Expand Up @@ -498,6 +514,7 @@ export class RedisDemoStore implements DemoStore {

export class MemoryDemoStore implements DemoStore {
private readonly sessions = new Map<string, ShadowDemoSessionState>();
private readonly sessionIdsByClientNonce = new Map<string, string>();
private readonly idempotency = new Map<string, IdempotencyRecord<unknown>>();
private readonly sharedCorpus = new Map<ShadowDemoCommunityId, ShadowDemoCorpus>();
private readonly locks = new Map<string, string>();
Expand All @@ -506,22 +523,36 @@ export class MemoryDemoStore implements DemoStore {
return this.sessions.get(sessionId) ?? null;
}

async readSessionIdByClientNonce(clientNonce: string): Promise<string | null> {
return this.sessionIdsByClientNonce.get(clientNonce) ?? null;
}

async createSession(
session: ShadowDemoSessionState,
_ttlSeconds: number,
maxActiveSessions: number
maxActiveSessions: number,
clientNonce: string
): Promise<boolean> {
const now = new Date(session.createdAt).getTime();
for (const [sessionId, candidate] of this.sessions.entries()) {
if (new Date(candidate.expiresAt).getTime() <= now) {
this.sessions.delete(sessionId);
for (const [nonce, mappedSessionId] of this.sessionIdsByClientNonce.entries()) {
if (mappedSessionId === sessionId) {
this.sessionIdsByClientNonce.delete(nonce);
}
}
}
}
if (this.sessionIdsByClientNonce.has(clientNonce)) {
return false;
}
if (this.sessions.size >= maxActiveSessions) {
return false;
}
serializeSession(session);
this.sessions.set(session.sessionId, JSON.parse(JSON.stringify(session)) as ShadowDemoSessionState);
this.sessionIdsByClientNonce.set(clientNonce, session.sessionId);
return true;
}

Expand Down Expand Up @@ -684,6 +715,10 @@ export function demoSessionKeyPrefix(): string {
return 'demo:session:';
}

export function demoSessionNonceKeyPrefix(): string {
return 'demo:session-nonce:';
}

export function demoCorpusKeyPrefix(): string {
return 'demo:corpus:';
}
Expand All @@ -708,6 +743,10 @@ function sessionKey(sessionId: string): string {
return `${demoSessionKeyPrefix()}${sessionId}`;
}

function sessionNonceKey(clientNonce: string): string {
return `${demoSessionNonceKeyPrefix()}${clientNonce}`;
}

function corpusKey(corpusId: string): string {
return `${demoCorpusKeyPrefix()}${corpusId}`;
}
Expand Down
2 changes: 2 additions & 0 deletions tests/demo-shadow-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
demoIdempotencyKeyPrefix,
demoLockKeyPrefix,
demoSessionKeyPrefix,
demoSessionNonceKeyPrefix,
demoSharedCorpusKeyPrefix,
demoStagingKeyPrefix,
} from '../src/demo/store.js';
Expand All @@ -20,6 +21,7 @@ const DEPLOY_FILE = new URL('../.github/workflows/deploy.yml', import.meta.url).
describe('shadow demo isolation guards', () => {
it('keeps Redis state inside the demo namespace', () => {
expect(demoSessionKeyPrefix()).toBe('demo:session:');
expect(demoSessionNonceKeyPrefix()).toBe('demo:session-nonce:');
expect(demoCorpusKeyPrefix()).toBe('demo:corpus:');
expect(demoSharedCorpusKeyPrefix()).toBe('demo:corpus:current:');
expect(demoIdempotencyKeyPrefix()).toBe('demo:idempotency:');
Expand Down
Loading
Loading