Skip to content

Commit 8040866

Browse files
author
CompareAIHardware Builder
committed
feat(share): slice 3 — play/remix counters + share_created/game_remixed funnel events
UNIT 1 (CEO ruling #4: aggregate integers only, zero PII): - HostedExport.counts {plays, remixes}, init at host time - getShareStats/incrementShareCount (never throws, integer-clamped) - GET /api/share/:token/stats -> {plays, remixes} (404/410, ACAO:* + no-store) - plays increment in shared serve handler (/share/:token + legacy view) - remixes increment only on real payload delivery - landing bar fetches stats post-render, subtle 'Played N times' UNIT 2 (storage-only funnel): - trackEvent('share_created', {hostedId}) on SharePopover success - trackEvent('game_remixed', {hostedId, projectId}) after RemixPage fork Tests: api +5 counter tests (incl. flatten-key PII sweep), web +3. Status log entry same-commit (session-23).
1 parent ab3db37 commit 8040866

9 files changed

Lines changed: 294 additions & 1 deletion

File tree

apps/api/src/routes/hostedRoutes.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export async function hostedRoutes(app: FastifyInstance) {
6464

6565
const { content, mimeType } = await hostedServiceInstance!.getHostedFile(hostedId);
6666

67+
// Play counter (slice 3): every serve of the game counts as a play —
68+
// both the canonical /share/:token link and the legacy view route come
69+
// through this shared handler. Awaited (tiny local write) so the
70+
// landing bar's own stats fetch sees its own view; never throws.
71+
await hostedServiceInstance!.incrementShareCount(hostedId, 'plays');
72+
6773
applyGameHtmlHeaders(reply);
6874
reply
6975
.header('Content-Type', mimeType)
@@ -207,6 +213,9 @@ export async function hostedRoutes(app: FastifyInstance) {
207213
code: 'remix_payload_missing',
208214
};
209215
}
216+
// Remix counter (slice 3): only real payload deliveries count —
217+
// unknown/expired/legacy-missing fetches are not remixes.
218+
await hostedServiceInstance!.incrementShareCount(token, 'remixes');
210219
return payload;
211220
} catch (error: any) {
212221
reply.code(500);
@@ -215,6 +224,36 @@ export async function hostedRoutes(app: FastifyInstance) {
215224
}
216225
);
217226

227+
// Share stats (slice 3): aggregate integers only (CEO ruling #4 exception
228+
// to the storage-only funnel — no PII). Consumed by the injected landing
229+
// bar ("played N times"), which runs inside a CSP-sandboxed page with an
230+
// opaque origin → its fetch is cross-origin, so ACAO:* is required.
231+
app.get<{ Params: { token: string } }>(
232+
'/api/share/:token/stats',
233+
async (request, reply) => {
234+
const { token } = request.params;
235+
try {
236+
const hostedExport = await hostedServiceInstance!.getHostedExport(token);
237+
if (!hostedExport) {
238+
reply.code(404);
239+
return { error: 'Hosted game not found' };
240+
}
241+
if (hostedExport.expiresAt && new Date(hostedExport.expiresAt) < new Date()) {
242+
reply.code(410);
243+
return { error: 'Hosted game has expired' };
244+
}
245+
const stats = await hostedServiceInstance!.getShareStats(token);
246+
reply
247+
.header('Access-Control-Allow-Origin', '*')
248+
.header('Cache-Control', 'no-store');
249+
return stats;
250+
} catch (error: any) {
251+
reply.code(500);
252+
return { error: error.message || 'Failed to load share stats' };
253+
}
254+
}
255+
);
256+
218257
// View hosted game in browser (serve HTML) — legacy route kept working;
219258
// new shares advertise /share/:token links instead.
220259
app.get<{ Params: { hostedId: string } }>(

apps/api/src/services/hostedService.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface HostedExport {
2222
downloadUrl: string;
2323
/** v1 ruling (CEO 2026-08-25): shares include full editable source by default. */
2424
sourceIncluded?: boolean;
25+
/** Aggregate play/remix counters (slice 3). Integers only — zero PII by design. */
26+
counts?: { plays: number; remixes: number };
2527
}
2628

2729
export interface HostedOptions {
@@ -207,6 +209,7 @@ export class HostedService {
207209
expiresAt,
208210
downloadUrl: `/api/projects/${projectId}/exports/${exportFilename}`,
209211
sourceIncluded: true,
212+
counts: { plays: 0, remixes: 0 },
210213
};
211214

212215
// Save hosted metadata
@@ -404,7 +407,7 @@ window.addEventListener('DOMContentLoaded', () => {
404407
\`;
405408
406409
nav.innerHTML = \`
407-
<div>
410+
<div id="clawgame-bar-left">
408411
<strong>🎮 ClawGame</strong>${expiresLine}
409412
</div>
410413
<div style="display:flex;align-items:center;gap:12px;">
@@ -422,6 +425,30 @@ window.addEventListener('DOMContentLoaded', () => {
422425
dismiss.addEventListener('click', () => nav.remove());
423426
}
424427
428+
// Play count (slice 3): subtle aggregate integer from the share-stats
429+
// endpoint — rendered only after it arrives, so a slow/failed fetch never
430+
// delays or breaks the game. The CSP sandbox gives this page an opaque
431+
// origin, making the fetch cross-origin; /api/share/:token/stats answers
432+
// with ACAO:*. Failure is silent by design.
433+
try {
434+
var meta = window.GAME_HOSTED_METADATA || {};
435+
if (meta.hostedId) {
436+
fetch('/api/share/' + encodeURIComponent(meta.hostedId) + '/stats')
437+
.then(function (r) { return r.ok ? r.json() : null; })
438+
.then(function (s) {
439+
if (!s || typeof s.plays !== 'number' || s.plays < 1) return;
440+
var left = document.getElementById('clawgame-bar-left');
441+
if (!left || document.getElementById('clawgame-play-count')) return;
442+
var span = document.createElement('span');
443+
span.id = 'clawgame-play-count';
444+
span.style.cssText = 'opacity:0.65;margin-left:8px;';
445+
span.textContent = 'Played ' + s.plays + (s.plays === 1 ? ' time' : ' times');
446+
left.appendChild(span);
447+
})
448+
.catch(function () {});
449+
}
450+
} catch (e) { /* counters must never break play */ }
451+
425452
// Adjust game container for nav bar
426453
const container = document.getElementById('game-container');
427454
if (container) {
@@ -435,6 +462,46 @@ window.addEventListener('DOMContentLoaded', () => {
435462
return injectedHtml;
436463
}
437464

465+
/**
466+
* Read the aggregate share counters for a token.
467+
* Returns null when the token is unknown; zeros for legacy metas written
468+
* before counters existed. Integers only — no PII is ever recorded here.
469+
*/
470+
async getShareStats(hostedId: string): Promise<{ plays: number; remixes: number } | null> {
471+
const hosted = await this.getHostedExport(hostedId);
472+
if (!hosted) return null;
473+
return {
474+
plays: Math.max(0, Math.trunc(hosted.counts?.plays ?? 0)),
475+
remixes: Math.max(0, Math.trunc(hosted.counts?.remixes ?? 0)),
476+
};
477+
}
478+
479+
/**
480+
* Increment one aggregate counter in `<id>.meta.json` (read-modify-write).
481+
*
482+
* CEO ruling #4 exception to the storage-only funnel: these are bare
483+
* integers on an already-public artifact's meta file — no IPs, no user
484+
* agents, no fingerprints, nothing per-visitor. Never throws: a failed
485+
* counter write must not break serving or remixing (callers rely on it).
486+
*/
487+
async incrementShareCount(hostedId: string, key: 'plays' | 'remixes'): Promise<void> {
488+
try {
489+
const hostedDir = await this.ensureHostedDir();
490+
const metaPath = join(hostedDir, `${hostedId}.meta.json`);
491+
if (!existsSync(metaPath)) return;
492+
const meta = JSON.parse(await readFile(metaPath, 'utf-8')) as HostedExport;
493+
const counts = {
494+
plays: Math.max(0, Math.trunc(meta.counts?.plays ?? 0)),
495+
remixes: Math.max(0, Math.trunc(meta.counts?.remixes ?? 0)),
496+
};
497+
counts[key] += 1;
498+
meta.counts = counts;
499+
await writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
500+
} catch (err) {
501+
this.logger.warn({ hostedId, key, err }, 'Failed to increment share counter');
502+
}
503+
}
504+
438505
/**
439506
* Get hosted export by ID
440507
*/

apps/api/src/test/share-remix.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ async function createFixtureProject(name = 'Remix Fixture', sceneName = 'Remix M
5252
return { id, scenePath };
5353
}
5454

55+
/** Recursively collect every object key path in a JSON value. */
56+
function flattenKeys(value: unknown, prefix = ''): string[] {
57+
if (value === null || typeof value !== 'object') return [];
58+
if (Array.isArray(value)) return value.flatMap((v) => flattenKeys(v, prefix));
59+
return Object.entries(value as Record<string, unknown>).flatMap(([k, v]) => [
60+
prefix ? `${prefix}.${k}` : k,
61+
...flattenKeys(v, prefix ? `${prefix}.${k}` : k),
62+
]);
63+
}
64+
5565
async function shareProject(app: any, projectId: string) {
5666
const res = await app.inject({ method: 'POST', url: `/api/projects/${projectId}/share` });
5767
expect(res.statusCode).toBe(201);
@@ -204,3 +214,105 @@ describe('serialized payload size cap (design §4)', () => {
204214
expect(() => assertPayloadWithinSize(oversized)).toThrow(/too large/i);
205215
});
206216
});
217+
218+
describe('share counters (slice 3, CEO ruling #4: aggregate integers only, zero PII)', () => {
219+
it('fresh shares start at zero and GET /api/share/:token/stats returns exactly {plays, remixes}', async () => {
220+
const app = await buildApp();
221+
const { id: projectId } = await createFixtureProject('Stats Fresh');
222+
const { hosted } = await shareProject(app, projectId);
223+
224+
const res = await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/stats` });
225+
expect(res.statusCode).toBe(200);
226+
expect(res.json()).toEqual({ plays: 0, remixes: 0 });
227+
228+
// CORS header is mandatory: the injected landing bar fetches stats from a
229+
// CSP-sandboxed page with an opaque origin (cross-origin request).
230+
expect(res.headers['access-control-allow-origin']).toBe('*');
231+
expect(res.headers['cache-control']).toBe('no-store');
232+
233+
await app.close();
234+
});
235+
236+
it('serving the game increments plays — /share/:token and legacy view route both count', async () => {
237+
const app = await buildApp();
238+
const { id: projectId } = await createFixtureProject('Stats Plays');
239+
const { hosted } = await shareProject(app, projectId);
240+
241+
await app.inject({ method: 'GET', url: `/share/${hosted.id}` });
242+
await app.inject({ method: 'GET', url: `/api/hosted/${hosted.id}/view` });
243+
await app.inject({ method: 'GET', url: `/share/${hosted.id}` });
244+
245+
const res = await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/stats` });
246+
expect(res.json()).toEqual({ plays: 3, remixes: 0 });
247+
248+
await app.close();
249+
});
250+
251+
it('remix payload fetches increment remixes; failed/legacy remix attempts do not', async () => {
252+
const app = await buildApp();
253+
const { id: projectId } = await createFixtureProject('Stats Remixes');
254+
const { hosted } = await shareProject(app, projectId);
255+
256+
await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/remix` });
257+
await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/remix` });
258+
259+
// Legacy share without sidecar → typed 404 must NOT count as a remix.
260+
const other = await createFixtureProject('Legacy Stats');
261+
const legacyShare = await shareProject(app, other.id);
262+
await unlink(join(HOSTED_DIR, `${legacyShare.hosted.id}.share.json`));
263+
await app.inject({ method: 'GET', url: `/api/share/${legacyShare.hosted.id}/remix` });
264+
expect(
265+
(await app.inject({ method: 'GET', url: `/api/share/${legacyShare.hosted.id}/stats` })).json(),
266+
).toEqual({ plays: 0, remixes: 0 });
267+
268+
const res = await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/stats` });
269+
expect(res.json()).toEqual({ plays: 0, remixes: 2 });
270+
271+
await app.close();
272+
});
273+
274+
it('404 unknown token, 410 expired token for stats', async () => {
275+
const app = await buildApp();
276+
const missing = await app.inject({ method: 'GET', url: '/api/share/no-such-token/stats' });
277+
expect(missing.statusCode).toBe(404);
278+
279+
const service = new HostedService(mockLogger);
280+
const { id: projectId } = await createFixtureProject('Expired Stats');
281+
const { ExportService } = await import('../services/exportService');
282+
const exportService = new ExportService(mockLogger);
283+
const exported = await exportService.exportToPhaserHTML(projectId, { format: 'phaser-html' });
284+
const hosted = await service.hostExport(projectId, exported.filename, { expiresInDays: 30 });
285+
const metaPath = join(HOSTED_DIR, `${hosted.id}.meta.json`);
286+
const meta = JSON.parse(await readFile(metaPath, 'utf-8'));
287+
meta.expiresAt = new Date(Date.now() - 1000).toISOString();
288+
await writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
289+
290+
const expired = await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/stats` });
291+
expect(expired.statusCode).toBe(410);
292+
293+
await app.close();
294+
});
295+
296+
it('meta file carries integers only — no PII keys ever written by counter paths', async () => {
297+
const app = await buildApp();
298+
const { id: projectId } = await createFixtureProject('Stats No PII');
299+
const { hosted } = await shareProject(app, projectId);
300+
301+
await app.inject({ method: 'GET', url: `/share/${hosted.id}` });
302+
await app.inject({ method: 'GET', url: `/api/share/${hosted.id}/remix` });
303+
304+
const raw = await readFile(join(HOSTED_DIR, `${hosted.id}.meta.json`), 'utf-8');
305+
const meta = JSON.parse(raw);
306+
expect(meta.counts).toEqual({ plays: 1, remixes: 1 });
307+
expect(Number.isInteger(meta.counts.plays)).toBe(true);
308+
expect(Number.isInteger(meta.counts.remixes)).toBe(true);
309+
310+
const FORBIDDEN = ['ip', 'ips', 'useragent', 'user-agent', 'ua', 'referer', 'referrer', 'fingerprint', 'sessionid'];
311+
const keys = Object.keys(flattenKeys(meta)).map((k) => k.toLowerCase());
312+
for (const forbidden of FORBIDDEN) {
313+
expect(keys.some((k) => k === forbidden || k.endsWith(`.${forbidden}`))).toBe(false);
314+
}
315+
316+
await app.close();
317+
});
318+
});

apps/web/src/components/ShareButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
1111
import { Link2, Copy, ExternalLink, Trash2, Loader2, X } from 'lucide-react';
1212
import { api, type HostedExport, type ShareResponse } from '../api/client';
1313
import { useToast } from './Toast';
14+
import { trackEvent } from '../utils/activationEvents';
1415

1516
interface ShareButtonProps {
1617
projectId: string;
@@ -85,6 +86,8 @@ const SharePopover: React.FC<{ projectId: string; projectName?: string; onClose:
8586
try {
8687
const res = await api.shareProject(projectId);
8788
if (res.success && res.hosted && res.url) {
89+
// Storage-only funnel (design §4): creator-side share event. Ids only.
90+
trackEvent('share_created', { hostedId: res.hosted.id });
8891
setState({ phase: 'result', link: res.url, hosted: res.hosted });
8992
void loadExisting();
9093
return;

apps/web/src/pages/RemixPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import React, { useEffect, useRef, useState } from 'react';
2020
import { useNavigate, useParams } from 'react-router-dom';
2121
import { api } from '../api/client';
2222
import { recordRecentProject } from '../utils/recentProjects';
23+
import { trackEvent } from '../utils/activationEvents';
2324
import './remix.css';
2425

2526
/** API origin for the "back to game" link (the playable share lives there). */
@@ -82,6 +83,10 @@ export const RemixPage: React.FC = () => {
8283

8384
recordRecentProject({ id: created.id, name, remixedFrom: token });
8485

86+
// Storage-only funnel (design §4): recipient-side remix event, fired
87+
// only after the fork fully succeeded. Ids only — no payload text.
88+
trackEvent('game_remixed', { hostedId: token, projectId: created.id });
89+
8590
navigate(`/project/${created.id}/editor`, { replace: true });
8691
} catch (err) {
8792
const status = (err as any)?.status;

apps/web/src/test/remix-page.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { render, screen, waitFor } from '@testing-library/react';
1313
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
1414
import { RemixPage } from '../pages/RemixPage';
1515
import { RECENT_PROJECTS_STORAGE_KEY } from '../utils/recentProjects';
16+
import { ACTIVATION_EVENTS_STORAGE_KEY } from '../utils/activationEvents';
1617

1718
const TOKEN = '6f9619ff-8b86-d011-b42d-00cf4fc964ff';
1819

@@ -136,6 +137,31 @@ describe('RemixPage — remix import flow (slice 2)', () => {
136137
expect(recent[0]).toMatchObject({ id: 'remixed-1', name: 'Remix of Space Shooter', remixedFrom: TOKEN });
137138
});
138139

140+
it('records a game_remixed funnel event (ids only) after the fork fully succeeds', async () => {
141+
installFetch();
142+
renderRemixPage();
143+
144+
await waitFor(() =>
145+
expect(screen.getByTestId('location-probe').textContent).toBe('/project/remixed-1/editor'),
146+
);
147+
148+
const events = JSON.parse(window.localStorage.getItem(ACTIVATION_EVENTS_STORAGE_KEY) || '[]');
149+
const remixEvent = events.find((e: any) => e.name === 'game_remixed');
150+
expect(remixEvent).toBeTruthy();
151+
expect(remixEvent.props).toEqual({ hostedId: TOKEN, projectId: 'remixed-1' });
152+
expect(typeof remixEvent.ts).toBe('string');
153+
});
154+
155+
it('failed imports record no game_remixed event', async () => {
156+
installFetch({ remixStatus: 404 });
157+
renderRemixPage();
158+
159+
await waitFor(() => screen.getByText(/Remix unavailable/i));
160+
161+
const events = JSON.parse(window.localStorage.getItem(ACTIVATION_EVENTS_STORAGE_KEY) || '[]');
162+
expect(events.filter((e: any) => e.name === 'game_remixed')).toHaveLength(0);
163+
});
164+
139165
it('invalid token (404): error card, no project created, back-to-game link offered', async () => {
140166
const { calls } = installFetch({ remixStatus: 404 });
141167
renderRemixPage();

apps/web/src/test/share-button.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
99
import { ToastProvider, ToastList } from '../components/Toast';
1010
import { ShareButton } from '../components/ShareButton';
11+
import { ACTIVATION_EVENTS_STORAGE_KEY } from '../utils/activationEvents';
1112

1213
const SHARE_OK = {
1314
success: true,
@@ -107,6 +108,21 @@ describe('ShareButton/SharePopover — one-click share (slice 1)', () => {
107108
expect(screen.getByText(/Includes full editable source/i)).toBeTruthy();
108109
});
109110

111+
it('records a share_created funnel event (ids only) when the link is created', async () => {
112+
installFetch(SHARE_OK);
113+
renderShareButton();
114+
await openPopover();
115+
116+
fireEvent.click(screen.getByText(/Create share link/i));
117+
await waitFor(() => expect(screen.getByDisplayValue(SHARE_OK.url)).toBeTruthy());
118+
119+
const events = JSON.parse(window.localStorage.getItem(ACTIVATION_EVENTS_STORAGE_KEY) || '[]');
120+
const shareEvent = events.find((e: any) => e.name === 'share_created');
121+
expect(shareEvent).toBeTruthy();
122+
expect(shareEvent.props).toEqual({ hostedId: SHARE_OK.hosted.id });
123+
expect(typeof shareEvent.ts).toBe('string');
124+
});
125+
110126
it('shows the export-stage error toast when export fails', async () => {
111127
installFetch({ success: false, stage: 'export', error: 'Project not found' }, 400);
112128
renderShareButton();

0 commit comments

Comments
 (0)