Manus Agent commited on
Commit
5d4cb85
·
1 Parent(s): 2fca7e3

Persist and protect cross-browser answer viewing

Browse files
Files changed (5) hide show
  1. plan.md +1 -1
  2. public/owner.html +8 -3
  3. public/owner.js +28 -2
  4. server.js +30 -7
  5. src/main.js +23 -1
plan.md CHANGED
@@ -1,3 +1,3 @@
1
 
2
  ## Persistent answer room
3
- Submissions are written atomically to JSON at `DATA_DIR/submissions.json`, defaulting to `/data` when available and falling back to the project data directory locally. The `/owner` dashboard and `/api/owner/submissions` endpoint provide cross-device access without a login, per request. This is intentionally private-by-URL rather than authenticated: anyone who knows the owner URL can read the records. A mounted persistent volume is required for the JSON file to survive Hugging Face container replacement.
 
1
 
2
  ## Persistent answer room
3
+ Submissions are written atomically to JSON at `DATA_DIR/submissions.json`, defaulting to `/data` when available and falling back to the project data directory locally. The home page discovers the latest saved submission after refresh or in another browser, then asks for `OWNER_VIEW_PASSWORD` before returning the answer. The `/owner` dashboard uses the same password-protected API and keeps the key only in the current tab. A Hugging Face persistent storage volume mounted at `/data` is required for the JSON file to survive Space/container replacement; without it, answers still survive browser refreshes while the current container is running, but not a recreated container.
public/owner.html CHANGED
@@ -9,7 +9,7 @@
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
11
  <style>
12
- :root{--ink:#2b1a16;--muted:#7a5b52;--paper:#f6ede6;--card:rgba(255,250,245,.72);--red:#a51f2b;--line:rgba(43,26,22,.16);--serif:'Cormorant Garamond',Georgia,serif;--sans:'Manrope',Arial,sans-serif;--mono:'DM Mono',monospace}*{box-sizing:border-box}body{margin:0;background:linear-gradient(140deg,#f2dfd2,#f0e0d4 52%,#d7b9a8);color:var(--ink);font-family:var(--sans)}main{width:min(1100px,calc(100% - 40px));margin:0 auto;padding:34px 0 80px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:70px}.brand{font-weight:800;font-size:14px}.brand span{color:var(--red)}.back{font:10px var(--mono);text-transform:uppercase;color:var(--muted);text-decoration:none}.eyebrow,.meta{font:10px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}h1{font:600 clamp(60px,9vw,120px)/.82 var(--serif);letter-spacing:-.06em;margin:0 0 24px}h1 em{color:var(--red);font-weight:500}.lede{max-width:510px;color:var(--muted);line-height:1.7;margin-bottom:44px}.notice{border:1px solid var(--line);background:rgba(255,250,245,.46);padding:16px 20px;max-width:700px;font-size:12px;line-height:1.6;margin-bottom:28px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:24px 0}.count{font:11px var(--mono);color:var(--muted);text-transform:uppercase}.button{border:0;border-radius:999px;background:var(--ink);color:white;padding:12px 18px;font-weight:800;cursor:pointer}.empty{padding:42px;border:1px dashed var(--line);color:var(--muted)}.grid{display:grid;gap:18px}.entry{background:var(--card);border:1px solid rgba(255,250,245,.82);box-shadow:12px 14px 0 rgba(164,91,79,.18);padding:24px;border-radius:2px 24px 2px 24px}.entry-head{display:flex;justify-content:space-between;gap:20px;align-items:start;border-bottom:1px solid var(--line);padding-bottom:15px;margin-bottom:18px}.entry h2{font:600 34px/.95 var(--serif);margin:0}.entry time{font:10px var(--mono);color:var(--muted);text-align:right}.label{font:10px var(--mono);text-transform:uppercase;color:var(--red);letter-spacing:.05em;margin:18px 0 7px}.answer{white-space:pre-wrap;line-height:1.65;font-size:14px;margin:0}.pills{display:flex;flex-wrap:wrap;gap:8px}.pill{font-size:12px;border:1px solid var(--line);padding:8px 10px;border-radius:999px;background:rgba(255,255,255,.3)}.review{border-left:3px solid var(--red);padding:13px 16px;background:rgba(165,31,43,.06);margin-top:8px}.score{font:700 22px var(--serif);color:var(--red)}.source{font:10px var(--mono);text-transform:uppercase;color:var(--muted);margin-left:8px}.status{font:10px var(--mono);text-transform:uppercase;color:var(--muted)}@media(max-width:600px){main{width:calc(100% - 28px);padding-top:20px}.top{margin-bottom:52px}.entry-head{display:block}.entry time{display:block;text-align:left;margin-top:10px}.toolbar{align-items:start;flex-direction:column}}
13
  </style>
14
  </head>
15
  <body>
@@ -18,8 +18,13 @@
18
  <p class="eyebrow">private room / no login enabled</p>
19
  <h1>What she<br /><em>said.</em></h1>
20
  <p class="lede">Every answer submitted through the journey is collected here, so you can read it from your own device after she finishes.</p>
21
- <div class="notice"><strong>Privacy note:</strong> this dashboard has no login, as requested. Anyone who knows this URL may be able to read these private answers. The JSON store is persistent only on the Space filesystem or mounted <code>/data</code> volume.</div>
22
- <div class="toolbar"><span class="count" id="count">loading the room…</span><button class="button" id="refresh" type="button">refresh answers ↻</button></div>
 
 
 
 
 
23
  <section class="grid" id="entries" aria-live="polite"></section>
24
  </main>
25
  <script src="/owner.js"></script>
 
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
11
  <style>
12
+ :root{--ink:#2b1a16;--muted:#7a5b52;--paper:#f6ede6;--card:rgba(255,250,245,.72);--red:#a51f2b;--line:rgba(43,26,22,.16);--serif:'Cormorant Garamond',Georgia,serif;--sans:'Manrope',Arial,sans-serif;--mono:'DM Mono',monospace}*{box-sizing:border-box}body{margin:0;background:linear-gradient(140deg,#f2dfd2,#f0e0d4 52%,#d7b9a8);color:var(--ink);font-family:var(--sans)}main{width:min(1100px,calc(100% - 40px));margin:0 auto;padding:34px 0 80px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:70px}.brand{font-weight:800;font-size:14px}.brand span{color:var(--red)}.back{font:10px var(--mono);text-transform:uppercase;color:var(--muted);text-decoration:none}.eyebrow,.meta{font:10px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}h1{font:600 clamp(60px,9vw,120px)/.82 var(--serif);letter-spacing:-.06em;margin:0 0 24px}h1 em{color:var(--red);font-weight:500}.lede{max-width:510px;color:var(--muted);line-height:1.7;margin-bottom:44px}.notice{border:1px solid var(--line);background:rgba(255,250,245,.46);padding:16px 20px;max-width:700px;font-size:12px;line-height:1.6;margin-bottom:28px}.owner-gate{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:22px 0 34px;max-width:700px}.owner-gate input{flex:1;min-width:220px;border:0;border-bottom:1px solid var(--ink);outline:0;background:transparent;color:var(--ink);padding:12px 0;font:12px var(--mono)}.gate-status{flex-basis:100%;font:10px var(--mono);text-transform:uppercase;color:var(--muted)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:24px 0}.count{font:11px var(--mono);color:var(--muted);text-transform:uppercase}.button{border:0;border-radius:999px;background:var(--ink);color:white;padding:12px 18px;font-weight:800;cursor:pointer}.empty{padding:42px;border:1px dashed var(--line);color:var(--muted)}.grid{display:grid;gap:18px}.entry{background:var(--card);border:1px solid rgba(255,250,245,.82);box-shadow:12px 14px 0 rgba(164,91,79,.18);padding:24px;border-radius:2px 24px 2px 24px}.entry-head{display:flex;justify-content:space-between;gap:20px;align-items:start;border-bottom:1px solid var(--line);padding-bottom:15px;margin-bottom:18px}.entry h2{font:600 34px/.95 var(--serif);margin:0}.entry time{font:10px var(--mono);color:var(--muted);text-align:right}.label{font:10px var(--mono);text-transform:uppercase;color:var(--red);letter-spacing:.05em;margin:18px 0 7px}.answer{white-space:pre-wrap;line-height:1.65;font-size:14px;margin:0}.pills{display:flex;flex-wrap:wrap;gap:8px}.pill{font-size:12px;border:1px solid var(--line);padding:8px 10px;border-radius:999px;background:rgba(255,255,255,.3)}.review{border-left:3px solid var(--red);padding:13px 16px;background:rgba(165,31,43,.06);margin-top:8px}.score{font:700 22px var(--serif);color:var(--red)}.source{font:10px var(--mono);text-transform:uppercase;color:var(--muted);margin-left:8px}.status{font:10px var(--mono);text-transform:uppercase;color:var(--muted)}@media(max-width:600px){main{width:calc(100% - 28px);padding-top:20px}.top{margin-bottom:52px}.entry-head{display:block}.entry time{display:block;text-align:left;margin-top:10px}.toolbar{align-items:start;flex-direction:column}.owner-gate{align-items:stretch;flex-direction:column}.owner-gate input{min-width:0;width:100%}}
13
  </style>
14
  </head>
15
  <body>
 
18
  <p class="eyebrow">private room / no login enabled</p>
19
  <h1>What she<br /><em>said.</em></h1>
20
  <p class="lede">Every answer submitted through the journey is collected here, so you can read it from your own device after she finishes.</p>
21
+ <div class="notice"><strong>Private room:</strong> enter the owner key to view saved answers. They are stored in the Space’s persistent JSON store at <code>/data/submissions.json</code>.</div>
22
+ <form class="owner-gate" id="ownerGate">
23
+ <input id="ownerPassword" type="password" autocomplete="current-password" placeholder="owner key" maxlength="200" required />
24
+ <button class="button" type="submit">view answers →</button>
25
+ <span class="gate-status" id="gateStatus" role="status">the key stays in this browser tab</span>
26
+ </form>
27
+ <div class="toolbar" hidden><span class="count" id="count">loading the room…</span><button class="button" id="refresh" type="button">refresh answers ↻</button></div>
28
  <section class="grid" id="entries" aria-live="polite"></section>
29
  </main>
30
  <script src="/owner.js"></script>
public/owner.js CHANGED
@@ -1,6 +1,11 @@
1
  const entries = document.querySelector('#entries');
2
  const count = document.querySelector('#count');
3
  const refresh = document.querySelector('#refresh');
 
 
 
 
 
4
 
5
  const text = (value) => String(value ?? '');
6
  const formatDate = (value) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
@@ -48,10 +53,16 @@ function renderSubmission(item) {
48
  }
49
 
50
  async function load() {
 
51
  count.textContent = 'loading the room…';
52
  refresh.disabled = true;
53
  try {
54
- const response = await fetch('/api/owner/submissions', { cache: 'no-store' });
 
 
 
 
 
55
  if (!response.ok) throw new Error('Could not load the room.');
56
  const payload = await response.json();
57
  entries.replaceChildren();
@@ -69,5 +80,20 @@ async function load() {
69
  }
70
  }
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  refresh.addEventListener('click', load);
73
- load();
 
1
  const entries = document.querySelector('#entries');
2
  const count = document.querySelector('#count');
3
  const refresh = document.querySelector('#refresh');
4
+ const ownerGate = document.querySelector('#ownerGate');
5
+ const ownerPassword = document.querySelector('#ownerPassword');
6
+ const gateStatus = document.querySelector('#gateStatus');
7
+ const toolbar = document.querySelector('.toolbar');
8
+ let password = '';
9
 
10
  const text = (value) => String(value ?? '');
11
  const formatDate = (value) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
 
53
  }
54
 
55
  async function load() {
56
+ if (!password) return;
57
  count.textContent = 'loading the room…';
58
  refresh.disabled = true;
59
  try {
60
+ const response = await fetch('/api/owner/submissions', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ cache: 'no-store',
64
+ body: JSON.stringify({ password }),
65
+ });
66
  if (!response.ok) throw new Error('Could not load the room.');
67
  const payload = await response.json();
68
  entries.replaceChildren();
 
80
  }
81
  }
82
 
83
+ ownerGate.addEventListener('submit', async (event) => {
84
+ event.preventDefault();
85
+ password = ownerPassword.value;
86
+ gateStatus.textContent = 'checking the key…';
87
+ try {
88
+ await load();
89
+ if (!entries.children.length) throw new Error('That key did not open the room.');
90
+ ownerGate.hidden = true;
91
+ toolbar.hidden = false;
92
+ } catch (error) {
93
+ password = '';
94
+ gateStatus.textContent = error.message;
95
+ ownerPassword.select();
96
+ }
97
+ });
98
+
99
  refresh.addEventListener('click', load);
 
server.js CHANGED
@@ -82,6 +82,17 @@ function findSubmission(id) {
82
  return submissions.find((submission) => submission.id === id);
83
  }
84
 
 
 
 
 
 
 
 
 
 
 
 
85
  async function rateSincerity(payload) {
86
  const apiKey = process.env.OPENROUTER_API_KEY;
87
  if (!apiKey) return localReflection(payload);
@@ -124,8 +135,23 @@ async function readBody(req) {
124
  }
125
 
126
  async function handleApi(req, res) {
127
- if (req.method === 'GET' && req.url === '/api/owner/submissions') {
128
- sendJson(res, 200, { submissions: [...submissions].sort((a, b) => b.createdAt - a.createdAt) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  return true;
130
  }
131
  if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) {
@@ -141,11 +167,8 @@ async function handleApi(req, res) {
141
  if (req.method === 'POST' && req.url === '/api/owner-unlock') {
142
  try {
143
  const payload = await readBody(req);
144
- const expected = process.env.OWNER_VIEW_PASSWORD || '';
145
- const supplied = cleanString(payload.password, 200);
146
- const submission = findSubmission(cleanString(payload.submissionId, 100));
147
- const matches = expected && supplied && Buffer.byteLength(expected) === Buffer.byteLength(supplied) && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));
148
- if (!matches || !submission) {
149
  sendJson(res, 401, { error: 'That key does not open this room.' });
150
  return true;
151
  }
 
82
  return submissions.find((submission) => submission.id === id);
83
  }
84
 
85
+ function latestSubmission() {
86
+ return [...submissions].sort((a, b) => b.createdAt - a.createdAt)[0];
87
+ }
88
+
89
+ function passwordMatches(supplied) {
90
+ const expected = process.env.OWNER_VIEW_PASSWORD || '';
91
+ const value = cleanString(supplied, 200);
92
+ return Boolean(expected && value && Buffer.byteLength(expected) === Buffer.byteLength(value)
93
+ && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(value)));
94
+ }
95
+
96
  async function rateSincerity(payload) {
97
  const apiKey = process.env.OPENROUTER_API_KEY;
98
  if (!apiKey) return localReflection(payload);
 
135
  }
136
 
137
  async function handleApi(req, res) {
138
+ if (req.method === 'GET' && req.url === '/api/latest-submission') {
139
+ const latest = latestSubmission();
140
+ sendJson(res, 200, latest ? { available: true, submissionId: latest.id } : { available: false });
141
+ return true;
142
+ }
143
+ if (req.method === 'POST' && req.url === '/api/owner/submissions') {
144
+ try {
145
+ const payload = await readBody(req);
146
+ if (!passwordMatches(payload.password)) {
147
+ sendJson(res, 401, { error: 'That key does not open this room.' });
148
+ return true;
149
+ }
150
+ sendJson(res, 200, { submissions: [...submissions].sort((a, b) => b.createdAt - a.createdAt) });
151
+ } catch (error) {
152
+ console.error('[owner-submissions]', error.message);
153
+ sendJson(res, 400, { error: 'The private key could not be checked.' });
154
+ }
155
  return true;
156
  }
157
  if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) {
 
167
  if (req.method === 'POST' && req.url === '/api/owner-unlock') {
168
  try {
169
  const payload = await readBody(req);
170
+ const submission = findSubmission(cleanString(payload.submissionId, 100)) || latestSubmission();
171
+ if (!passwordMatches(payload.password) || !submission) {
 
 
 
172
  sendJson(res, 401, { error: 'That key does not open this room.' });
173
  return true;
174
  }
src/main.js CHANGED
@@ -222,7 +222,7 @@ function renderOwnerLock() {
222
  const title = document.createElement('strong');
223
  title.textContent = 'sealed for his eyes';
224
  const copy = document.createElement('p');
225
- copy.textContent = 'Her exact words and the sincerity read are tucked away. Enter the owner key to open the private room.';
226
  const row = document.createElement('div');
227
  row.className = 'private-key-row';
228
  const input = document.createElement('input');
@@ -346,6 +346,26 @@ async function requestSincerity() {
346
  }
347
  }
348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  function relockDecision() {
350
  state.unlocked = false;
351
  decisionStatus.textContent = 'locked until it’s honest';
@@ -436,6 +456,7 @@ sincerityResult.addEventListener('click', async (event) => {
436
  apologyInput.value = state.apology;
437
  state.sincerity = submission.reading;
438
  state.ownerUnlocked = true;
 
439
  setAnswerFieldsDisabled(true);
440
  renderBouquet();
441
  renderSincerity(state.sincerity);
@@ -533,3 +554,4 @@ const observer = new IntersectionObserver((entries) => {
533
  $$('.reveal').forEach((el) => observer.observe(el));
534
 
535
  renderBouquet();
 
 
222
  const title = document.createElement('strong');
223
  title.textContent = 'sealed for his eyes';
224
  const copy = document.createElement('p');
225
+ copy.textContent = 'Her exact words and the sincerity read are tucked away. Enter the owner key to view the answer.';
226
  const row = document.createElement('div');
227
  row.className = 'private-key-row';
228
  const input = document.createElement('input');
 
346
  }
347
  }
348
 
349
+ async function restorePersistedAnswer() {
350
+ try {
351
+ const response = await fetch('/api/latest-submission', { cache: 'no-store' });
352
+ if (!response.ok) return;
353
+ const payload = await response.json();
354
+ if (!payload.available || !payload.submissionId) return;
355
+ state.submissionId = payload.submissionId;
356
+ state.sincerity = { sealed: true };
357
+ state.sealed = true;
358
+ state.ownerUnlocked = false;
359
+ setAnswerFieldsDisabled(true);
360
+ $('#name').classList.add('is-sealed');
361
+ $('#promises').classList.add('is-sealed');
362
+ renderSincerity(state.sincerity);
363
+ updateUnlockState();
364
+ } catch {
365
+ // A temporary discovery failure should not prevent a new answer from being written.
366
+ }
367
+ }
368
+
369
  function relockDecision() {
370
  state.unlocked = false;
371
  decisionStatus.textContent = 'locked until it’s honest';
 
456
  apologyInput.value = state.apology;
457
  state.sincerity = submission.reading;
458
  state.ownerUnlocked = true;
459
+ consentCheck.checked = true;
460
  setAnswerFieldsDisabled(true);
461
  renderBouquet();
462
  renderSincerity(state.sincerity);
 
554
  $$('.reveal').forEach((el) => observer.observe(el));
555
 
556
  renderBouquet();
557
+ restorePersistedAnswer();