← Commit history
README.md+11
@@ -278,6 +278,14 @@ already running on loopback in the container can read the data file directly.  Run with `--no-auth` to turn all of this off for a single-user local session. +**Who can open the plan (0.68.0).** Each plan has a sharing setting on the Setup page:+anyone signed in to Adom (the default), members of one Adom organization, or only the+owner. The server enforces it on every request, including the live channel, so a change+takes effect at once for everyone. Everyone it lets in has full control. The owner is+named on the server (`POST /access {"owner":"<username>"}` with the agent token); only+the owner can change the setting or set the Google Chat webhook. Before every import or+compact, and once a day, the plan is backed up to `<plan>-backups/` (last 30 kept).+ ### Letting an AI agent write, without breaking the history  Reading is open over loopback, but writing needs a signed-in person, and an agent has no@@ -426,6 +434,9 @@ loopback are open. `/health` is public and leaks nothing but counts. | Endpoint | Method | Description | |----------|--------|-------------| | `/health` | GET | Public. Rev, event count, and the changelog's `integrity` object |+| `/access` | GET or POST | Who can open the plan: `{mode: any|org|me, org, owner}`. The owner or the agent token changes it; the owner is named with the agent token |+| `/notify` | GET or POST | Google Chat digests: status (never the webhook), cadence, test message; the owner or the agent token sets the webhook |+| `/live` | GET | Server-sent events: revisions, presence, and `denied` when the plan stops being shared with you | | `/data` | GET | Current project data | | `/state` | GET | Saved edits (sidecar) | | `/save` | POST | Persist edits |
SKILL.md+49−3
@@ -290,6 +290,50 @@ View preferences (theme, milestone style, cell size, today marker, name column w `localStorage` per browser. They are a view concern, so they are not written to the data file. The project start is NOT a view preference: it edits real dates. +## Sharing: who can open a plan (0.68.0)++Every plan has one sharing setting, enforced by the server on every request (the page,+the data, every save, the live channel), never only in the page:++- **any**: anyone signed in to Adom. Every plan starts here, which is what every plan was+  before 0.68.0.+- **org**: members of one Adom organization, plus the owner. A person's memberships are+  read from Carbon once, at sign-in, and kept with the session (names only, no token); a+  session from before 0.68.0 carries none and is asked to sign in again.+- **me**: the owner only.++Everyone it lets in has full control, and every change is in the changelog under their+name. Someone outside gets a page saying the plan is not shared with them (a 403 with+`notShared: true` on the API), and a live stream they had open is told and closed the+moment the setting changes.++The **owner** is named on the server, with the agent token or from this machine, never by+whoever signs in first. Only the owner (and the server's operators) can change the+setting, and only the owner can set the Google Chat webhook from the Setup page. The+agent token, direct-loopback reads and `--no-auth` are unaffected: they are the server's+own operators, not visitors. The setting lives in `<data>-access.json` (mode 0600).++```bash+TOK=$(cat <plan>-agent-token)+curl -s -X POST localhost:8901/access -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"owner":"drew2"}'+curl -s -X POST localhost:8901/access -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"mode":"org","org":"adom"}'+curl -s localhost:8901/access     # {mode, org, owner, you, youAreOwner, canChange, authOff}+```++Every change of the setting is a changelog event (`access`): "Drew Owens shared the plan+with the adom organization (was anyone signed in to Adom)".++**Backups.** Before anything replaces the data file (every import, a compact) and once a+day, the plan and its sidecar are copied into `<plan>-backups/<stamp>-<why>/`, keeping+the last 30. With a team on a plan, one import replaces everybody's work; this makes it a+restore: copy the two files back and restart.++**Hosting options.** `--host <addr>` listens on an address other than 127.0.0.1, for a+port mapped to a public subdomain that needs the container's own address.+`--no-loopback` stops treating requests from 127.0.0.1 as this machine's own, for a+server that a proxy on the same machine forwards public traffic to; operators then use+the agent token.+ ## Google Chat digests (0.67.0)  Changes to a plan can be posted to a Google Chat space through an incoming webhook, as@@ -305,9 +349,11 @@ backfills, sidecar upgrades) is left out; the agent's edits are in, under its na   twice. A failed post is retried (30 seconds, doubling, at most 15 minutes, or what Chat   asks for) and the error shows on the Setup card. - **The webhook is a credential.** It is kept in `<data>-chat.json` (mode 0600) beside the-  plan, never sent to a page, never logged, and scrubbed from any error. Only a request-  from this machine or one carrying the agent token can set it (or the public address-  used for links); a page cannot. Only Google Chat webhook URLs are accepted.+  plan, never sent to a page, never logged, and scrubbed from any error. The plan's owner+  sets it from the Setup page (0.68.0: a field that takes the URL and never shows it+  again, and a two-click remove); so can a request from this machine or one carrying the+  agent token, which is also how the public address used for links is set. Nobody else,+  whatever the sharing. Only Google Chat webhook URLs are accepted. - **Cadence**: `live` (the digests above, the default), `hourly`, `daily` (5 pm Central),   or `off`. Turning it back on starts from then rather than dumping the gap. - **Setup page card**: whether it is set up, the space (its first characters only), the
docs/BUILD-NOTES.md+36
@@ -3,6 +3,42 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-14 (0.68.0, who can open a plan)++Step 2 of the sharing plan: the gate that must be up before the real plan goes on a public+address. "Info is confidential and must always be gated behind login w/ adom acct. Per+doc/project, I should be able to set share permissions: everyone w/ link, only org, only+me."++Sign-in now reads the person's org memberships from Carbon (`GET /user/memberships`) with+the token it holds for that one moment, and keeps the names with the session; the token+is still never stored. The plan's setting (`<data>-access.json`, 0600) is `any` (what+every plan was), `org` (one org by name, plus the owner) or `me` (the owner only). It is+checked on the server after the sign-in gate, for every session request, including+`/live`: a page gets a "not shared with you" page, the API a 403 with `notShared`. A+session from before 0.68.0 has no org list, so under `org` it is dropped and the browser+sees the sign-in page again. When the owner tightens the setting, every open stream of+someone it no longer lets in gets a `denied` event and is closed; the page raises the+save-blocked bar with the export offered, so unsaved work is not lost.++The owner is named on the server (agent token or this machine), never claimed from a+page. The owner alone changes the setting (and may not name an org they are not in, which+would lock out the members by accident) and alone sets the Chat webhook from the Setup+page: a field that takes the URL and clears, and a two-click remove. The agent token,+direct loopback and `--no-auth` are the server's operators and are unaffected. Each+change of the setting is an `access` changelog event in words.++Backups: before every import and compact, and once a day, into `<plan>-backups/`, last 30+kept. `--host` and `--no-loopback` for the cloud container (step 3): the first for a port+mapping that needs the container's address, the second for a same-machine proxy, where+"from 127.0.0.1" says nothing about who sent it.++Tests: 40 server checks against a fake Adom identity service (orgs at sign-in, an org list+that cannot be read, the default, the owner named only with the token, the owner's own+orgs, every route refused including /live, the member's stream closed, the old session+asked to sign in again, the backups and their cap, --no-loopback), and browser flows for+the Sharing card and the owner's webhook field.+ ## Build notes, 2026-10-14 (0.67.0, changes posted to Google Chat as digests)  Step 1 of the sharing plan Drew approved (docs/plan-collab.md, "Sharing with the team"):
docs/plan-collab.md+6−3
@@ -84,7 +84,10 @@ changelog becomes the op log's storage; `?format=md` is unchanged. The same-fiel appears. The agent token writes ops too, so an AI editing the plan and a person editing it no longer collide either. -### Checkpoint 3 (0.68.0): sharing+### Checkpoint 3 (later): sharing links++(0.68.0 went to the sharing SETTING and the org gate instead, see BUILD-NOTES 2026-10-14;+the links below are still planned, after field-level ops.)  1. **Share a link to a row**: every row has a URL (`#row=00042`); opening it scrolls to    the row, selects it and opens its editor. The item editor gets a "copy link".@@ -224,8 +227,8 @@ hour?) to a private gchat space via webhook."  ### Proposed order -1. Chat digests (0.67.0): usable immediately, on whatever server Drew uses today.-2. Org gate and the sharing setting, owner-only settings, backup before import (0.68.0).+1. Chat digests (0.67.0): usable immediately, on whatever server Drew uses today. BUILT 2026-10-14.+2. Org gate and the sharing setting, owner-only settings, backup before import (0.68.0). BUILT 2026-10-14. 3. The cloud container, the port mapping, nightly backups, the plan moved over; Drew    uses it first, alone. 4. Field-level ops (checkpoint 2), then decide whether to invite the team.
gantt.html+97−5
@@ -1268,6 +1268,9 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .chat-status { font-size: 11.5px; color: var(--text2); line-height: 1.5; } .chat-status .bad { color: var(--alarm); } #chatState.on { color: var(--accent); }+/* The Sharing card and the owner's webhook field (0.68.0). */+#shareState.on { color: var(--accent); }+.chat-row input[type="text"] { flex: 1 1 260px; min-width: 0; max-width: 520px; } .sec-add { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; } .sec-add input[type="text"] { flex: 1; min-width: 0; max-width: 360px; } .setup-view input[type="text"], .setup-view select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }@@ -1708,9 +1711,23 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va         <div class="sec-cols" aria-hidden="true"><span class="c-name">Section</span><span class="sec-count">Rows</span><span class="sec-dates">Dates</span><span class="sec-acts" style="visibility:hidden"><span class="sec-ghost">Remove</span></span></div>         <div class="sec-list" role="list"></div>       </div>+      <div class="an-card su-card" id="suShare">+        <div class="su-head"><h3>Sharing</h3><span class="su-n" id="shareState"></span></div>+        <p class="an-sub">Who can open this plan. Everyone it lets in can change it, and every change is in the changelog under their name. Only the plan's owner can change this.</p>+        <div class="chat-row">+          <label for="shareMode">Open to</label>+          <select id="shareMode" aria-label="Who can open this plan">+            <option value="me">Only the owner</option>+            <option value="org">Members of an organization</option>+            <option value="any">Anyone signed in to Adom</option>+          </select>+          <select id="shareOrg" aria-label="Which organization" style="display:none"></select>+        </div>+        <div class="chat-status" id="shareStatus"></div>+      </div>       <div class="an-card su-card" id="suChat">         <div class="su-head"><h3>Google Chat</h3><span class="su-n" id="chatState"></span></div>-        <p class="an-sub">Changes to this plan are posted to a Google Chat space as digests: grouped by person, in one thread per day. The space's webhook is a credential, so it is set on the server and never reaches this page.</p>+        <p class="an-sub">Changes to this plan are posted to a Google Chat space as digests: grouped by person, in one thread per day. The space's webhook is a credential: only the plan's owner can set it, and once set it is never shown again.</p>         <div class="chat-row">           <label for="chatCadence">Post</label>           <select id="chatCadence" aria-label="How often changes are posted to Google Chat">@@ -1721,6 +1738,11 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va           </select>           <button type="button" class="de-btn secondary" id="chatTest">Send a test message</button>         </div>+        <div class="chat-row" id="chatHookRow" style="display:none">+          <input type="text" id="chatHook" placeholder="Paste the space's incoming webhook URL" autocomplete="off" spellcheck="false" aria-label="Google Chat incoming webhook URL">+          <button type="button" class="de-btn secondary" id="chatHookSave">Set webhook</button>+          <button type="button" class="de-btn secondary" id="chatHookClear" style="display:none">Remove webhook</button>+        </div>         <div class="chat-status" id="chatStatus"></div>       </div>     </div></div>@@ -4512,6 +4534,8 @@ function saveToFile(){         return;       }       recovering=false;+      if(r.status===403){ onSaveBlocked('This plan is not shared with you any more, so the server refused the save.'); return; }+      if(r.status===401){ onSaveBlocked('Your sign-in has ended, so the server refused the save. Export your version, then reload to sign in again.'); return; }       if(!r.ok){ onSaveBlocked('The server refused the save ('+r.status+').'); return; }       if(typeof d.stateRev==='number')baseRev=d.stateRev;       clearDirty(sent);@@ -4596,6 +4620,9 @@ function startLive(){       pullAndMerge().catch(()=>{});       return;     }+    // The owner changed the sharing and this person is no longer on it (0.68.0). The+    // stream is closed by the server; the bar offers the export before anything is lost.+    if(m.type==='denied'){ stopLive(); onSaveBlocked('The owner changed who this plan is shared with, and it is not shared with you any more.'); return; }     if(m.type==='reload'){       flash(saveInd,(m.actor||'Someone')+' imported a plan, reloading',3000);       setTimeout(()=>location.reload(),1200);@@ -12467,7 +12494,7 @@ function renderAnalytics(){ // prefs: that runs before this line does. const VIEWS=VIEW_LIST; function applyView(){-  if(prefs.view==='setup'&&typeof renderChatCard==='function')renderChatCard();+  if(prefs.view==='setup'&&typeof renderChatCard==='function'){ renderShareCard(); renderChatCard(); }   const v = VIEWS.includes(prefs.view) ? prefs.view : 'gantt';   document.documentElement.setAttribute('data-view',v);   const vt=$('viewToggle');@@ -12494,10 +12521,15 @@ async function renderChatCard(){   let st=null;   try{ const r=await fetch('notify'); if(r.ok)st=await r.json(); }catch(e){}   const sel=$('chatCadence'), test=$('chatTest'), stEl=$('chatStatus'), state=$('chatState');-  if(!st){ state.textContent='unavailable'; state.className='su-n'; stEl.textContent='The server did not answer.'; sel.disabled=test.disabled=true; return; }+  const hookRow=$('chatHookRow');+  if(!st){ state.textContent='unavailable'; state.className='su-n'; stEl.textContent='The server did not answer.'; sel.disabled=test.disabled=true; hookRow.style.display='none'; return; }   sel.value=st.cadence; sel.disabled=!st.configured; test.disabled=!st.configured||st.cadence==='off';   state.textContent=!st.configured?'not set up':st.cadence==='off'?'off':'on'; state.className='su-n'+(st.configured&&st.cadence!=='off'?' on':'');-  if(!st.configured){ stEl.textContent='No space is set up for this plan yet. The plan\'s owner adds a Google Chat incoming webhook on the server.'; return; }+  // The owner's field (0.68.0): a place to paste a webhook, never a place it is shown.+  hookRow.style.display=st.canSetWebhook?'':'none';+  $('chatHook').placeholder=st.configured?'Paste a new webhook URL to move to another space':'Paste the space\'s incoming webhook URL';+  $('chatHookClear').style.display=st.configured?'':'none';+  if(!st.configured){ stEl.textContent=st.canSetWebhook?'No space is set up for this plan yet. In Google Chat, open the space, then Apps & integrations, Webhooks, and add one; paste its URL here.':'No space is set up for this plan yet. The plan\'s owner sets one up here.'; return; }   const parts=['Posting to '+st.space+'.'];   if(st.lastPostAt)parts.push('Last post '+agoText(st.lastPostAt)+'.');   if(st.pending)parts.push(st.pending+' change'+(st.pending===1?'':'s')+' waiting for the next digest.');@@ -12505,7 +12537,9 @@ async function renderChatCard(){ } function wireChatCard(){   const sel=$('chatCadence'), test=$('chatTest'); if(!sel||!test)return;-  sel.addEventListener('change',async()=>{+  // A pick, not each step of the arrows, changes the setting: the same list as every other.+  wireAppSelect(sel);+  sel.addEventListener('apppick',async()=>{     try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cadence:sel.value})});       flash(saveInd,r.ok?'Chat posting: '+sel.options[sel.selectedIndex].text.toLowerCase():'could not change the Chat setting',3000); }catch(e){ flash(saveInd,'could not reach the server',3000); }     renderChatCard();@@ -12517,6 +12551,63 @@ function wireChatCard(){       flash(saveInd,r.ok?'test message sent to Google Chat':'the test did not go through: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }     renderChatCard();   });+  const hook=$('chatHook'), set=$('chatHookSave'), clr=$('chatHookClear');+  const sendHook=async(value,done)=>{+    try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({webhook:value})});+      const d=await r.json().catch(()=>({}));+      if(r.ok){ hook.value=''; flash(saveInd,done(d),4000); }+      else flash(saveInd,'the webhook was not set: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }+    renderChatCard();+  };+  set.addEventListener('click',()=>{ const v=hook.value.trim(); if(!v){ hook.focus(); return; } sendHook(v,d=>'Google Chat: posting to '+(d.space||'the space')); });+  hook.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); set.click(); } });+  // Two clicks, like every other step that loses something: the URL is not shown again.+  clr.addEventListener('click',()=>{+    if(clr.dataset.armed!=='1'){+      clr.dataset.armed='1'; clr.textContent='Confirm remove';+      setTimeout(()=>{ if(clr.dataset.armed==='1'){ clr.dataset.armed='0'; clr.textContent='Remove webhook'; } },4000);+      return;+    }+    clr.dataset.armed='0'; clr.textContent='Remove webhook';+    sendHook('',()=>'Google Chat: webhook removed');+  });+}+// ── The Sharing card on Setup (0.68.0) ──+// Who can open the plan: only its owner, members of one Adom org, or anyone signed in to+// Adom. The server enforces it on every request; this card shows it and, for the owner,+// changes it. The orgs offered are the owner's own, as read at their sign-in.+function shareText(a){ return a.mode==='me'?'only the owner':a.mode==='org'?a.org+' members':'anyone signed in to Adom'; }+async function renderShareCard(){+  const card=$('suShare'); if(!card)return;+  if(SNAPSHOT){ card.style.display='none'; return; }+  let a=null;+  try{ const r=await fetch('access'); if(r.ok)a=await r.json(); }catch(e){}+  const mode=$('shareMode'), org=$('shareOrg'), st=$('shareStatus'), state=$('shareState');+  if(!a){ state.textContent='unavailable'; state.className='su-n'; st.textContent='The server did not answer.'; mode.disabled=org.disabled=true; return; }+  const orgs=[...new Set([a.org].concat((a.you&&a.you.orgs)||[]))];+  org.innerHTML=orgs.map(o=>'<option value="'+esc(o)+'">'+esc(o)+'</option>').join('');+  mode.value=a.mode; org.value=a.org; org.style.display=a.mode==='org'?'':'none';+  mode.disabled=org.disabled=!a.canChange;+  state.textContent=shareText(a); state.className='su-n'+(a.mode==='any'?'':' on');+  const owner=a.owner?(a.owner.displayName||a.owner.username):'';+  const parts=[];+  if(a.authOff)parts.push('Sign-in is off on this server, so this is not enforced here: anyone who can reach it can open the plan.');+  parts.push(owner?'Owner: '+owner+(a.youAreOwner?' (you).':'.'):'No owner is named yet; that is done on the server.');+  if(!a.canChange)parts.push('Only the owner can change who this plan is shared with.');+  else if(a.mode!=='me')parts.push('Changing this takes effect at once, for everyone, including anyone who has the plan open.');+  st.textContent=parts.join(' ');+}+function wireShareCard(){+  const mode=$('shareMode'), org=$('shareOrg'); if(!mode||!org)return;+  wireAppSelect(mode); wireAppSelect(org);+  const send=async body=>{+    try{ const r=await fetch('access',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});+      const d=await r.json().catch(()=>({}));+      flash(saveInd,r.ok?'sharing: '+shareText(d):'sharing was not changed: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }+    renderShareCard();+  };+  mode.addEventListener('apppick',()=>{ const b={mode:mode.value}; if(mode.value==='org')b.org=org.value||'adom'; send(b); });+  org.addEventListener('apppick',()=>send({mode:'org',org:org.value})); } function setView(v){   if(!VIEWS.includes(v))return;@@ -12613,6 +12704,7 @@ applyCollapse(); lastSnap=snapSemantic(); startLive(); wireChatCard();+wireShareCard(); // The page is built and the plan is in. The suite waits for this rather than for the // network to go quiet, which it never does once the live stream is open (0.66.0). document.documentElement.dataset.ready='1';
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.67.0",+  "version": "0.68.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.67.0",+  "version": "0.68.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
server.js+203−13
@@ -10,9 +10,17 @@ const dataIdx = args.indexOf('--data'); const portIdx = args.indexOf('--port'); const DATA_FILE = dataIdx >= 0 ? path.resolve(args[dataIdx + 1]) : null; const PORT = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : 8901;+// --host: the address to listen on (0.68.0). 127.0.0.1 unless a hosting setup needs the+// container's own address, as a port mapped to a public subdomain may.+const hostIdx = args.indexOf('--host');+const HOST = hostIdx >= 0 && args[hostIdx + 1] ? args[hostIdx + 1] : '127.0.0.1';+// --no-loopback: never treat a request as coming from this machine. For a server that a+// proxy on the same machine forwards public traffic to: there, "from 127.0.0.1" says+// nothing about who sent it. Operators then use the agent token.+const NO_LOOPBACK = args.includes('--no-loopback');  if (!DATA_FILE) {-  console.error('Usage: node server.js --data <project.json> [--port 8901]');+  console.error('Usage: node server.js --data <project.json> [--port 8901] [--host 127.0.0.1] [--no-auth] [--no-loopback]');   process.exit(1); } if (!fs.existsSync(DATA_FILE)) {@@ -167,6 +175,7 @@ function agentTokenOk(presented) { const FWD_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip',                      'cf-connecting-ip', 'cf-ray', 'forwarded']; function isDirectLoopback(req) {+  if (NO_LOOPBACK) return false;   const a = req.socket.remoteAddress || '';   if (!(a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1')) return false;   if (FWD_HEADERS.some(h => req.headers[h])) return false;@@ -178,6 +187,57 @@ function isDirectLoopback(req) { // a signed-in person no matter where it came from. const LOOPBACK_READS = new Set(['/data', '/state', '/changelog',   '/export/json', '/export/csv', '/export/svg', '/export/html']);+// ── Sharing (0.68.0) ──+//+// Who may open and edit this plan, per plan, enforced HERE on every request (the page,+// the data, every save, the live channel), never only in the page. Three settings:+//   any  anyone signed in to Adom (what every plan was before 0.68.0, and the default)+//   org  members of one Adom org (read from Carbon at sign-in), and the owner+//   me   the owner only+// Everyone it lets in has full control. The owner is the one person who can change the+// setting and set the Chat webhook; the owner is named on the server (agent token or this+// machine), never claimed by whoever signs in first. The agent token, direct loopback reads+// and --no-auth are unaffected: they are the server's own operators, not visitors.+const ACCESS_FILE = DATA_FILE.replace(/\.json$/, '-access.json');+const ACCESS_MODES = ['any', 'org', 'me'];+function accessLoad() {+  try { const o = JSON.parse(fs.readFileSync(ACCESS_FILE, 'utf8')); if (o && typeof o === 'object' && !Array.isArray(o)) return o; } catch (e) {}+  return {};+}+function accessSave(o) { fs.writeFileSync(ACCESS_FILE, JSON.stringify(o, null, 1) + '\n', { encoding: 'utf8', mode: 0o600 }); }+function accessView() {+  const a = accessLoad();+  return { mode: ACCESS_MODES.includes(a.mode) ? a.mode : 'any', org: typeof a.org === 'string' && a.org ? a.org : 'adom',+    owner: a.owner && (a.owner.username || a.owner.id) ? a.owner : null };+}+function isOwner(user, a) {+  if (!user || !a || !a.owner) return false;+  if (a.owner.id && user.id && String(a.owner.id) === String(user.id)) return true;+  return !!(a.owner.username && user.username && a.owner.username.toLowerCase() === String(user.username).toLowerCase());+}+// { ok } or { ok:false, code: 401 (sign in again) | 403 (not shared with you) }+function accessCheck(auth) {+  if (!auth || auth.mode !== 'session') return { ok: true };+  const a = accessView(), u = auth.user || {};+  if (a.mode === 'any' || isOwner(u, a)) return { ok: true };+  if (a.mode === 'org') {+    // A session from before 0.68.0 carries no orgs: sign in again to have them read.+    if (!Array.isArray(u.orgs)) return { ok: false, code: 401 };+    if (u.orgs.some(o => String(o).toLowerCase() === a.org.toLowerCase())) return { ok: true };+  }+  return { ok: false, code: 403 };+}+function notSharedPage(user, a) {+  const who = esc((user && (user.displayName || user.username)) || 'you');+  const owner = esc((a.owner && (a.owner.displayName || a.owner.username)) || 'its owner');+  const why = a.mode === 'org' ? `It is shared with the <b>${esc(a.org)}</b> organization, and your Adom account is not a member.` : 'Only its owner can open it.';+  return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Not shared with you</title>+<style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0d1117;color:#e6edf3;font-family:'Familjen Grotesk',-apple-system,'Segoe UI',sans-serif}+.card{width:min(440px,92vw);background:#161b22;border:1px solid #30363d;border-radius:12px;padding:30px}h1{margin:0 0 10px;font-size:19px}p{margin:0 0 14px;font-size:13px;line-height:1.55;color:#8b949e}+button{padding:9px 14px;font:inherit;font-size:13px;font-weight:600;color:#e6edf3;background:#21262d;border:1px solid #30363d;border-radius:7px;cursor:pointer}</style></head>+<body><div class="card"><h1>This plan isn't shared with you</h1><p>You are signed in as <b>${who}</b>. ${why}</p><p>Ask ${owner} to share it, or sign in with another account.</p>+<button onclick="fetch('auth/signout',{method:'POST'}).then(()=>location.reload())">Sign out</button></div></body></html>`;+} function authFor(req, urlPath) {   // An Authorization header is an explicit claim to be the agent, so it decides the   // request on its own: the right token, or a 401 exactly as if nothing had been@@ -198,6 +258,16 @@ function authFor(req, urlPath) {   return { ok: false }; } +// The orgs a person belongs to, read once at sign-in with the token we hold for that moment+// (0.68.0), so a plan shared with an org can let its members in. Names only; no roles,+// no token kept. An answer we cannot read is "no orgs", never an error at sign-in.+async function carbonOrgs(token) {+  const r = await fetch(CARBON + '/user/memberships', { headers: { Authorization: 'Bearer ' + token } });+  if (!r.ok) throw new Error('carbon /user/memberships ' + r.status);+  const j = await r.json();+  const rows = Array.isArray(j) ? j : (Array.isArray(j && j.data) ? j.data : Array.isArray(j && j.items) ? j.items : []);+  return [...new Set(rows.map(m => m && m.organization && (m.organization.name || m.organization.login)).filter(x => typeof x === 'string' && x))];+} async function carbonUser(token) {   const r = await fetch(CARBON + '/user', { headers: { Authorization: 'Bearer ' + token } });   if (!r.ok) throw new Error('carbon /user ' + r.status);@@ -356,6 +426,31 @@ function writeAtomic(file, text) {   fs.renameSync(tmp, file); } function writeDataFile(data) { writeAtomic(DATA_FILE, JSON.stringify(data, null, 2) + '\n'); }+// ── Backups (0.68.0) ──+// A copy of the plan (data file and sidecar) is taken before anything replaces the data+// file (every import, a compact) and once a day, into <plan>-backups/, keeping the last+// 30. With a team on a plan, one import replaces everybody's work; this makes it a restore.+const BACKUP_DIR = DATA_FILE.replace(/\.json$/, '-backups');+function backupPlan(why) {+  try {+    const stamp = new Date().toISOString().replace(/[:.]/g, '-');+    const dir = path.join(BACKUP_DIR, stamp + '-' + String(why || 'backup').replace(/[^A-Za-z0-9-]/g, ''));+    fs.mkdirSync(dir, { recursive: true });+    if (fs.existsSync(DATA_FILE)) fs.copyFileSync(DATA_FILE, path.join(dir, path.basename(DATA_FILE)));+    if (fs.existsSync(STATE_FILE)) fs.copyFileSync(STATE_FILE, path.join(dir, path.basename(STATE_FILE)));+    const all = fs.readdirSync(BACKUP_DIR).filter(d => fs.statSync(path.join(BACKUP_DIR, d)).isDirectory()).sort();+    for (const d of all.slice(0, Math.max(0, all.length - 30))) fs.rmSync(path.join(BACKUP_DIR, d), { recursive: true, force: true });+    return dir;+  } catch (e) { console.error('adom-gantt: backup failed:', e.message); return null; }+}+let lastDailyBackupDay = '';+function dailyBackupTick() {+  const day = new Date().toISOString().slice(0, 10);+  if (day === lastDailyBackupDay) return;+  lastDailyBackupDay = day;+  try { if (fs.existsSync(BACKUP_DIR) && fs.readdirSync(BACKUP_DIR).some(d => d.startsWith(day) && d.endsWith('daily'))) return; } catch (e) {}+  backupPlan('daily');+} function backupOnce(file, suffix) {   const dest = file + suffix;   if (fs.existsSync(file) && !fs.existsSync(dest)) fs.copyFileSync(file, dest);@@ -429,7 +524,7 @@ function liveOpen(req, res, auth, query) {   res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',     'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });   res.write('retry: 3000\n\n');-  const c = { res, cid, who: whoOf(auth) };+  const c = { res, cid, who: whoOf(auth), auth };   liveClients.add(c);   liveSend(c, { type: 'hello', stateRev: stateRev(), you: c.who });   broadcastPresence();@@ -437,6 +532,20 @@ function liveOpen(req, res, auth, query) {   const bye = () => { clearInterval(hb); if (liveClients.delete(c)) broadcastPresence(); };   req.on('close', bye); res.on('close', bye); res.on('error', bye); }+// After the sharing changes, the open streams of people it no longer lets in are told and+// closed, so nobody goes on watching a plan that has stopped being shared with them.+function dropRefusedLive() {+  let n = 0;+  for (const c of [...liveClients]) {+    if (accessCheck(c.auth).ok) continue;+    liveSend(c, { type: 'denied' });+    liveClients.delete(c);+    try { c.res.end(); } catch (e) {}+    n++;+  }+  if (n) broadcastPresence();+  return n;+} function currentRev() {   const log = readLog();   return log.length ? log[log.length - 1].rev : 0;@@ -854,6 +963,10 @@ function describeEvent(e) {     if (!t) return `${who} cleared the label on ${name} (was "${f}")${via}`;     return `${who} relabelled ${name} "${f}" -> "${t}"${via}`;   }+  if (action === 'access') {+    const say = side => { const m = fld(side, 'mode', 'any'); return m === 'me' ? 'its owner only' : m === 'org' ? `the ${fld(side, 'org', 'adom')} organization` : 'anyone signed in to Adom'; };+    return `${who} shared the plan with ${say('to')} (was ${say('from')})${via}`;+  }   if (action === 'owner') {     const f = fld('from', 'owner', ''), t = fld('to', 'owner', '');     if (!f) return `${who} assigned ${name} to ${t || 'nobody'}${via}`;@@ -1890,6 +2003,7 @@ function importJSON(body) {   // carries are KEPT: an AI handed a list of numbers must still be able to use   // them after the round trip. Absent, duplicated or malformed ones are minted.   const uids = stampUids(data);+  backupPlan('import');   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   // Clear state file since data structure changed   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);@@ -2003,6 +2117,7 @@ function importCSV(body, queryString) {   // Same rule as the JSON import: a CSV that came out of here goes back in on   // the same numbers, and a CSV that never had a uid column gets fresh ones.   const uids = stampUids(data);+  backupPlan('import');   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);   return { ok: true, items: items.length, phases: phases.length, uids };@@ -2097,6 +2212,7 @@ function importMSProjectXML(body) {   // A foreign tool's ids mean nothing in this plan, so this one numbers from the   // top rather than pretending an MS Project UID is a handle anybody here holds.   const uids = stampUids(data, { fresh: true });+  backupPlan('import');   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);   return { ok: true, items: tasks.length, uids };@@ -2213,6 +2329,7 @@ function importJiraCSV(body) {   // Fresh numbers, for the same reason as the MS Project importer: a Jira key is   // Jira's handle on the row, not this plan's.   const uids = stampUids(data, { fresh: true });+  backupPlan('import');   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);   return { ok: true, items: validItems.length, uids };@@ -2508,6 +2625,7 @@ const server = http.createServer(async (req, res) => {       let user;       try { user = await carbonUser(st.session_token); }       catch (e) { jsonResp(res, 502, { ok: false, error: 'could not read your Adom profile' }); return; }+      try { user.orgs = await carbonOrgs(st.session_token); } catch (e) { user.orgs = []; }       const sid = newSession(user);       setCookie(req, res, sid);       jsonResp(res, 200, { ok: true, user });@@ -2565,6 +2683,23 @@ const server = http.createServer(async (req, res) => {       jsonResp(res, 401, { ok: false, error: 'sign in with your Adom account to use this plan', signIn: '/' });       return;     }+    {+      const acc = accessCheck(auth);+      if (!acc.ok) {+        const wantsHtml = String(req.headers.accept || '').includes('text/html');+        if (acc.code === 401) {+          // An old session with no orgs: drop it, and the page asks for a fresh sign-in.+          if (auth.sid) { sessions.delete(auth.sid); saveSessions(); }+          res.setHeader('Set-Cookie', COOKIE + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');+          if (req.method === 'GET' && wantsHtml) { res.writeHead(401, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); res.end(signInPage()); return; }+          jsonResp(res, 401, { ok: false, error: 'sign in again so your organizations can be read', signIn: '/' });+          return;+        }+        if (req.method === 'GET' && wantsHtml) { res.writeHead(403, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); res.end(notSharedPage(auth.user, accessView())); return; }+        jsonResp(res, 403, { ok: false, error: 'this plan is not shared with you', notShared: true });+        return;+      }+    }      // ── Existing endpoints ── @@ -2703,20 +2838,73 @@ const server = http.createServer(async (req, res) => {     // client is v4-only on purpose, and the one translator lives on this side.     if (urlPath === '/live') { liveOpen(req, res, auth, query); return; } +    if (urlPath === '/access') {+      const a = accessView();+      const you = auth.user || null;+      const admin = !!auth.agent || isDirectLoopback(req);+      const owner = isOwner(you, a);+      if (req.method === 'GET') {+        jsonResp(res, 200, { ok: true, mode: a.mode, org: a.org,+          owner: a.owner ? { displayName: a.owner.displayName || a.owner.username || '', username: a.owner.username || '' } : null,+          you: you ? { displayName: you.displayName || '', username: you.username || '', orgs: Array.isArray(you.orgs) ? you.orgs : [] } : null,+          youAreOwner: owner, canChange: owner || admin, authOff: AUTH_OFF });+        return;+      }+      if (req.method !== 'POST') { jsonResp(res, 405, { ok: false, error: 'GET or POST' }); return; }+      let body;+      try { body = JSON.parse((await readBody(req)) || '{}'); } catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON' }); return; }+      if (!body || typeof body !== 'object' || Array.isArray(body)) { jsonResp(res, 400, { ok: false, error: 'body must be an object' }); return; }+      const cur = accessLoad();+      const before = accessView();+      if ('owner' in body) {+        // Naming the owner is the server operator's call: the agent token or this machine.+        if (!admin) { jsonResp(res, 403, { ok: false, error: 'the owner is named on the server' }); return; }+        const o = body.owner;+        if (o === null || o === '') delete cur.owner;+        else if (typeof o === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(o)) cur.owner = { username: o };+        else if (o && typeof o === 'object' && (typeof o.username === 'string' || o.id)) cur.owner = { username: String(o.username || ''), id: o.id ? String(o.id) : undefined, displayName: typeof o.displayName === 'string' ? o.displayName.slice(0, 80) : undefined };+        else { jsonResp(res, 400, { ok: false, error: 'owner is an Adom username' }); return; }+      }+      if ('mode' in body || 'org' in body) {+        if (!(owner || admin)) { jsonResp(res, 403, { ok: false, error: 'only the plan\'s owner can change who it is shared with' }); return; }+        if ('mode' in body) { if (!ACCESS_MODES.includes(body.mode)) { jsonResp(res, 400, { ok: false, error: 'mode is one of ' + ACCESS_MODES.join(', ') }); return; } cur.mode = body.mode; }+        if ('org' in body) { if (typeof body.org !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(body.org)) { jsonResp(res, 400, { ok: false, error: 'org is an Adom organization name' }); return; } cur.org = body.org; }+        // An owner who names an org they are not in would lock the members out by mistake;+        // only the server operator may do that deliberately.+        if (!admin && cur.mode === 'org' && you && Array.isArray(you.orgs) && !you.orgs.some(x => x.toLowerCase() === String(cur.org || 'adom').toLowerCase())) {+          jsonResp(res, 400, { ok: false, error: 'you are not a member of ' + (cur.org || 'adom') }); return;+        }+      }+      // The owner's display name, filled the first time they are seen signed in.+      if (owner && you && cur.owner && !cur.owner.displayName) { cur.owner.displayName = you.displayName; if (you.id) cur.owner.id = String(you.id); }+      accessSave(cur);+      const after = accessView();+      if (before.mode !== after.mode || before.org !== after.org || JSON.stringify(before.owner) !== JSON.stringify(after.owner)) {+        try {+          appendLog([{ action: 'access', from: { mode: before.mode, org: before.org, owner: before.owner && (before.owner.displayName || before.owner.username) },+            to: { mode: after.mode, org: after.org, owner: after.owner && (after.owner.displayName || after.owner.username) }, via: 'setup' }],+            auth.agent ? AGENT_ACTOR : (you ? (you.displayName || you.username) : null));+        } catch (e) {}+      }+      dropRefusedLive();+      jsonResp(res, 200, Object.assign({ ok: true }, after, { youAreOwner: isOwner(you, after) }));+      return;+    }+     if (urlPath === '/notify') {-      if (req.method === 'GET') { jsonResp(res, 200, chatStatus()); return; }+      if (req.method === 'GET') { jsonResp(res, 200, Object.assign(chatStatus(), { canSetWebhook: !!auth.agent || isDirectLoopback(req) || isOwner(auth.user, accessView()) })); return; }       if (req.method !== 'POST') { jsonResp(res, 405, { ok: false, error: 'GET or POST' }); return; }       let body;       try { body = JSON.parse((await readBody(req)) || '{}'); } catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON' }); return; }       if (!body || typeof body !== 'object' || Array.isArray(body)) { jsonResp(res, 400, { ok: false, error: 'body must be an object' }); return; }-      // The webhook and the plan's public address are set from this machine or with the-      // agent token, never from a page: one is a credential, the other goes into every-      // message. (The plan's owner gets a field for them with the sharing setting.)-      const admin = !!auth.agent || isDirectLoopback(req);+      // The webhook and the plan's public address are set by the plan's owner (0.68.0: a+      // field on Setup), from this machine, or with the agent token: one is a credential,+      // the other goes into every message. Nobody else, whatever the sharing.+      const admin = !!auth.agent || isDirectLoopback(req) || isOwner(auth.user, accessView());       const cfg = chatLoad();       const headRev = currentRev();       if ('webhook' in body) {-        if (!admin) { jsonResp(res, 403, { ok: false, error: 'the webhook is set on the server, not from a page' }); return; }+        if (!admin) { jsonResp(res, 403, { ok: false, error: 'only the plan\'s owner can set the webhook' }); return; }         if (body.webhook === null || body.webhook === '') { delete cfg.webhook; delete cfg.lastError; }         else if (!chatWebhookOk(body.webhook)) { jsonResp(res, 400, { ok: false, error: 'that is not a Google Chat incoming webhook URL' }); return; }         else { cfg.webhook = body.webhook; cfg.cursor = headRev; delete cfg.lastError; if (!cfg.cadence) cfg.cadence = 'live'; }@@ -2819,6 +3007,7 @@ const server = http.createServer(async (req, res) => {           adds: (state && state.added || []).length,           deletes: (state && state.deleted || []).length,         };+        backupPlan('compact');         fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf8');         if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);         logCompact(info, auth.agent ? AGENT_ACTOR : null);@@ -3002,20 +3191,21 @@ ensureStateV4();  // The digest tick: decides on its own whether anything is due, so a quiet plan costs one // small file read every 15 seconds and nothing else.-const chatTimer = setInterval(() => { chatTick().catch(() => {}); }, CHAT_TICK_MS);+const chatTimer = setInterval(() => { chatTick().catch(() => {}); try { dailyBackupTick(); } catch (e) {} }, CHAT_TICK_MS); if (chatTimer.unref) chatTimer.unref();-server.listen(PORT, '127.0.0.1', () => {-  console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`);+server.listen(PORT, HOST, () => {+  console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://${HOST.includes(':') ? '[' + HOST + ']' : HOST}:${PORT}`);   console.log(`State file: ${STATE_FILE}`);   console.log(`Data dir: ${DATA_DIR}`);-  console.log(`Endpoints: /health /data /state /live /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+  console.log(`Endpoints: /health /data /state /live /access /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);   const integ = integrityStatus();   console.log(integ.ok     ? `Changelog integrity: OK (hash chain verified${integ.chained ? '' : ', not yet chained'})`     : `Changelog integrity: TAMPER — chain broken at rev ${integ.brokenAtRev}: ${integ.reason}`);   console.log(AUTH_OFF     ? 'Auth: OFF (--no-auth). Anyone who can reach this port can read and change the plan.'-    : 'Auth: Sign in with Adom required. Direct-loopback GETs of /data /state /changelog /export/* stay open for the AI handoff loop.');+    : 'Auth: Sign in with Adom required.' + (NO_LOOPBACK ? ' Loopback is not trusted (--no-loopback).' : ' Direct-loopback GETs of /data /state /changelog /export/* stay open for the AI handoff loop.'));+  { const a = accessView(); console.log(`Sharing: ${a.mode === 'me' ? 'owner only' : a.mode === 'org' ? 'the ' + a.org + ' organization' : 'anyone signed in to Adom'}; owner: ${a.owner ? (a.owner.username || a.owner.id) : 'not set'}. Backups: ${BACKUP_DIR}`); }   // The path, never the token. A startup log is read over shoulders and pasted into   // issues; what an agent needs from it is where to look, not the credential itself.   console.log(AGENT_TOKEN
test/server_test.mjs+154−2
@@ -57,14 +57,15 @@ async function freePort(from) {   throw new Error('no free port in 8940-8949'); } -async function start(name, plan, sidecar, fromPort, extraEnv) {+async function start(name, plan, sidecar, fromPort, extraEnv, opts = {}) {   const dir = path.join(TMP, name);   fs.mkdirSync(dir, { recursive: true });   const data = path.join(dir, 'plan.json');   fs.writeFileSync(data, JSON.stringify(plan, null, 2) + '\n');   if (sidecar) fs.writeFileSync(path.join(dir, 'plan-state.json'), JSON.stringify(sidecar));   const port = await freePort(fromPort);-  const p = spawn(process.execPath, [SERVER, '--data', data, '--port', String(port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, extraEnv || {}) });+  const argv = [SERVER, '--data', data, '--port', String(port)].concat(opts.auth ? [] : ['--no-auth'], opts.args || []);+  const p = spawn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, extraEnv || {}) });   let log = '';   p.stdout.on('data', d => { log += d; });   p.stderr.on('data', d => { log += d; });@@ -492,6 +493,157 @@ async function main() {   ok(r.status === 400, 'an unknown cadence is refused');   stop(CH2); fake.close();   }++  // ── Sharing (0.68.0): sign-in reads the person's orgs, and the plan's setting decides+  // who gets in. Against a fake Adom identity service on loopback. Requests carry an+  // X-Forwarded-For, as everything through a public address does, so none of them is+  // "this machine".+  {+  const http = await import('node:http');+  const people = {+    drew: { id: 'u1', name: 'drew2', display_name: 'Drew Owens', orgs: ['adom', 'cosmiic'] },+    ana: { id: 'u2', name: 'ana', display_name: 'Ana Lopez', orgs: ['adom'] },+    zed: { id: 'u3', name: 'zed', display_name: 'Zed Out', orgs: ['elsewhere'] },+    bob: { id: 'u4', name: 'bob', display_name: 'Bob Broken', orgs: null },+  };+  const intents = new Map(); let nextWho = 'drew', seq = 0;+  const carbon = http.createServer((req, res) => {+    const send = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };+    const who = String(req.headers.authorization || '').replace(/^Bearer S-/, '');+    if (req.method === 'POST' && req.url === '/auth/intents') { const t = 'T' + (++seq); intents.set(t, nextWho); send(200, { token: t, auth_url: 'http://example.invalid/approve' }); return; }+    const m = req.url.match(/^\/auth\/intents\/([^/]+)\/status$/);+    if (m) { const w = intents.get(m[1]); send(200, w ? { state: 'authenticated', session_token: 'S-' + w } : { state: 'pending' }); return; }+    const p = people[who];+    if (req.url === '/user' && p) { send(200, { id: p.id, name: p.name, display_name: p.display_name }); return; }+    if (req.url === '/user/memberships' && p) { if (!p.orgs) { send(500, { error: 'down' }); return; } send(200, p.orgs.map(o => ({ is_owner: false, is_public: true, organization: { id: 'o-' + o, name: o } }))); return; }+    send(401, { error: 'no' });+  });+  const carbonPort = await new Promise(r => carbon.listen(0, '127.0.0.1', () => r(carbon.address().port)));+  const env = { ADOM_CARBON_API: `http://127.0.0.1:${carbonPort}`, ADOM_GANTT_CHAT_TICK_MS: '300' };+  const XFF = { 'X-Forwarded-For': '203.0.113.9' };+  let S = await start('share', basePlan(false), null, 8946, env, { auth: true });+  const signIn = async who => {+    nextWho = who;+    const st = await (await fetch(S.base + '/auth/start', { method: 'POST', headers: XFF })).json();+    const r = await fetch(S.base + '/auth/poll?token=' + st.token, { headers: XFF });+    const j = await r.json();+    return { cookie: (r.headers.get('set-cookie') || '').split(';')[0], user: j.user };+  };+  const as = async (who, u, opts = {}) => {+    const r = await fetch(S.base + u, Object.assign({}, opts, { headers: Object.assign({}, XFF, who ? { Cookie: who.cookie } : {}, opts.headers || {}) }));+    let j = null; const t = await r.text(); try { j = JSON.parse(t); } catch (e) {}+    return { status: r.status, j, t };+  };+  const asPost = (who, u, body) => as(who, u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });+  const agent = (u, body) => post(S, u, body, { Authorization: 'Bearer ' + S.token });+  const html = { headers: { Accept: 'text/html' } };+  const drew = await signIn('drew'), ana = await signIn('ana'), zed = await signIn('zed'), bob = await signIn('bob');+  ok(eq(drew.user && drew.user.orgs, ['adom', 'cosmiic']), 'sign-in reads the person\'s Adom orgs', drew.user);+  ok(eq(bob.user && bob.user.orgs, []), 'an org list that cannot be read is "no orgs", and sign-in still works', bob.user);+  let r = await as(zed, '/data');+  ok(r.status === 200, 'a new plan is open to anyone signed in to Adom, as before 0.68.0', r.status);+  r = await as(null, '/data');+  ok(r.status === 401, 'and to nobody who is not signed in', r.status);+  r = await as(zed, '/access');+  ok(r.status === 200 && r.j.mode === 'any' && r.j.owner === null && r.j.canChange === false, 'GET /access: the setting, no owner yet, and zed cannot change it', r.j);+  r = await asPost(zed, '/access', { mode: 'me' });+  ok(r.status === 403, 'someone who is not the owner cannot change the sharing', r.status);+  r = await asPost(drew, '/access', { owner: 'drew2' });+  ok(r.status === 403, 'nobody names themselves the owner from a page', r.status);+  r = await agent('/access', { owner: 'drew2' });+  ok(r.status === 200 && r.j.owner && r.j.owner.username === 'drew2', 'the owner is named with the agent token', r.j);+  r = await as(drew, '/access');+  ok(r.j.youAreOwner === true && r.j.canChange === true && eq(r.j.you.orgs, ['adom', 'cosmiic']), 'the owner sees they can change it, and their orgs', r.j);+  r = await asPost(drew, '/access', { mode: 'org', org: 'elsewhere' });+  ok(r.status === 400, 'the owner cannot share with an org they are not in (it would lock themselves out of the choice)', r);+  r = await asPost(drew, '/access', { mode: 'org', org: 'adom' });+  ok(r.status === 200 && r.j.mode === 'org' && r.j.org === 'adom', 'the owner shares the plan with the adom org', r.j);+  const af = path.join(S.dir, 'plan-access.json');+  ok(fs.existsSync(af) && (fs.statSync(af).mode & 0o777) === 0o600, 'the setting is kept in a 0600 file beside the plan');+  ok(JSON.parse(fs.readFileSync(af, 'utf8')).owner.displayName === 'Drew Owens', 'the owner\'s name is filled in the first time they are seen');+  r = await as(ana, '/data');+  ok(r.status === 200, 'a member of adom gets in', r.status);+  r = await as(zed, '/data');+  ok(r.status === 403 && r.j && r.j.notShared === true, 'someone outside adom is refused, with a 403 that says why', r);+  r = await as(zed, '/', html);+  ok(r.status === 403 && r.t.includes('isn\'t shared with you') && r.t.includes('adom') && !r.t.includes('Etch'), 'their browser gets a page that says so, and none of the plan', r.status);+  for (const u of ['/state', '/changelog', '/export/json', '/live?cid=z', '/notify', '/access']) {+    const x = await as(zed, u);+    if (x.status !== 403) ok(false, 'refused on ' + u, x.status);+  }+  ok(true, 'and refused on every route: /state /changelog /export /live /notify /access');+  r = await asPost(zed, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [] });+  ok(r.status === 403, 'a save from outside the org is refused', r.status);+  r = await as(bob, '/data');+  ok(r.status === 403, 'someone whose orgs could not be read is outside every org', r.status);+  r = await as(drew, '/notify');+  ok(r.j && r.j.canSetWebhook === true, 'the owner may set the Chat webhook from the page', r.j);+  r = await as(ana, '/notify');+  ok(r.j && r.j.canSetWebhook === false, 'a member may not', r.j);+  r = await asPost(ana, '/notify', { webhook: 'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=K&token=T' });+  ok(r.status === 403, 'and is refused if they try', r.status);+  r = await asPost(drew, '/notify', { webhook: 'https://example.com/hook' });+  ok(r.status === 400, 'the owner\'s webhook is still checked for being a Chat webhook', r.status);+  r = await asPost(ana, '/notify', { cadence: 'hourly' });+  ok(r.status === 200, 'anyone the plan is shared with can change how often it posts', r.status);+  // The live stream of someone the owner stops sharing with is told and closed.+  const live = await fetch(S.base + '/live?cid=ana1', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });+  const reader = live.body.getReader(); let got = '';+  const reading = (async () => { const t0 = Date.now(); while (Date.now() - t0 < 5000) { const { value, done } = await reader.read(); if (done) return 'closed'; got += Buffer.from(value).toString(); } return 'timeout'; })();+  await new Promise(res => setTimeout(res, 300));+  r = await asPost(drew, '/access', { mode: 'me' });+  ok(r.status === 200 && r.j.mode === 'me', 'the owner makes it owner-only', r.j);+  const how = await reading;+  ok(how === 'closed' && got.includes('"type":"denied"'), 'an open live stream of a member is told it is denied, and closed', [how, got.slice(-200)]);+  r = await as(ana, '/data');+  ok(r.status === 403, 'the member is now refused', r.status);+  r = await as(drew, '/data');+  ok(r.status === 200, 'the owner is not', r.status);+  r = await agent('/data', undefined);+  ok((await fetch(S.base + '/data', { headers: { Authorization: 'Bearer ' + S.token } })).status === 200, 'the agent token is unaffected by the sharing');+  const md = await (await fetch(S.base + '/changelog?format=md', { headers: { Authorization: 'Bearer ' + S.token } })).text();+  ok(md.includes('shared the plan with the adom organization (was anyone signed in to Adom)') && md.includes('shared the plan with its owner only (was the adom organization)'), 'each change of sharing is in the changelog, in words', md.split('\n').filter(l => /shared/.test(l)));+  // A session from before 0.68.0 has no orgs: under "org" it is asked to sign in again.+  r = await asPost(drew, '/access', { mode: 'org', org: 'adom' });+  stop(S); await new Promise(res => setTimeout(res, 300));+  const sf = path.join(S.dir, 'plan-sessions.json');+  const sess = JSON.parse(fs.readFileSync(sf, 'utf8'));+  const anaSid = decodeURIComponent(ana.cookie.split('=')[1]);+  delete sess[anaSid].user.orgs;+  fs.writeFileSync(sf, JSON.stringify(sess));+  S = await start('share', basePlan(false), null, 8946, env, { auth: true });+  r = await as(ana, '/data');+  ok(r.status === 401 && /sign in again/.test(r.j && r.j.error), 'an old session with no orgs is asked to sign in again', r);+  r = await as(ana, '/data');+  ok(r.status === 401, 'and that session is gone', r.status);+  const ana2 = await signIn('ana');+  r = await as(ana2, '/data');+  ok(r.status === 200, 'signing in again reads the orgs, and lets them in', r.status);+  // Backups: before every import and compact, and one a day, keeping the last 30.+  const bdir = path.join(S.dir, 'plan-backups');+  ok(await (async () => { for (let i = 0; i < 30; i++) { if (fs.existsSync(bdir) && fs.readdirSync(bdir).some(d => d.endsWith('-daily'))) return true; await new Promise(res => setTimeout(res, 100)); } return false; })(), 'a daily backup is taken once the server is up');+  r = await post(S, '/import/json', basePlan(true), { Authorization: 'Bearer ' + S.token });+  const imp = fs.readdirSync(bdir).filter(d => d.endsWith('-import'));+  ok(r.status === 200 && imp.length === 1 && fs.existsSync(path.join(bdir, imp[0], 'plan.json')), 'an import first backs up the plan it replaces', [r.status, fs.readdirSync(bdir)]);+  ok(imp.length === 1 && !JSON.parse(fs.readFileSync(path.join(bdir, imp[0], 'plan.json'), 'utf8')).items[0].tags, 'and the backup is the plan as it was before the import');+  for (let i = 0; i < 35; i++) fs.mkdirSync(path.join(bdir, '2000-01-01T00-00-' + String(i).padStart(2, '0') + '-000Z-old'), { recursive: true });+  r = await post(S, '/compact', {}, { Authorization: 'Bearer ' + S.token });+  const after = fs.readdirSync(bdir);+  ok(after.some(d => d.endsWith('-compact')) && after.length === 30 && !after.includes('2000-01-01T00-00-00-000Z-old'), 'a compact backs up first, and only the last 30 backups are kept', [r.status, after.length]);+  stop(S);+  // --no-loopback: a request from this machine is nobody special.+  const L = await start('noloop', basePlan(false), null, 8947, env, { auth: true, args: ['--no-loopback'] });+  r = await fetch(L.base + '/data');+  ok(r.status === 401, 'with --no-loopback, a loopback read without a session is refused', r.status);+  r = await fetch(L.base + '/data', { headers: { Authorization: 'Bearer ' + L.token } });+  ok(r.status === 200, 'and the agent token still works', r.status);+  stop(L);+  const L2 = await start('loop', basePlan(false), null, 8947, env, { auth: true });+  r = await fetch(L2.base + '/data');+  ok(r.status === 200, 'without it, a direct loopback read still works for the AI handoff loop', r.status);+  stop(L2);+  carbon.close();+  } }  try { await main(); }
test/ui_test.mjs+61−3
@@ -225,7 +225,11 @@ async function startServer(plan, name) { const pageErrors = []; // Designed failures from the app's own server, each named with the reason. Empty: the // app serves nothing that is meant to fail in these states. Status 0 = request failed.-const ALLOWED_FAILURES = [];+const ALLOWED_FAILURES = [+  // The notify flow pastes a URL that is not a Chat webhook into the owner's field: the+  // server refuses it with a 400, which is the point of the check.+  { path: /^\/notify$/, status: 400 },+]; async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today = TODAY, clear = true, ctx = null } = {}) {   const page = await (ctx || browser).newPage();   const tag = `${w}px ${url.replace(/^.*\/\/[^/]+/, '').slice(0, 40) || '/'}`;@@ -840,6 +844,7 @@ async function main() {     if (wantAny('effort')) await effortFlow(browser);     if (wantAny('collab')) await collabFlow(browser);     if (wantAny('notify')) await notifyFlow(browser);+    if (wantAny('share')) await shareFlow(browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -1137,6 +1142,11 @@ const MANIFEST = [   { sel: '#bEffort', label: 'batch: effort to set', terminal: true, by: 'effort' },   { sel: '#chatCadence', label: 'setup: how often changes go to Google Chat', terminal: true, by: 'notify' },   { sel: '#chatTest', label: 'setup: send a test message to Google Chat', terminal: true, by: 'notify' },+  { sel: '#chatHook', label: 'setup: the owner\'s webhook field', terminal: true, by: 'notify' },+  { sel: '#chatHookSave', label: 'setup: set the webhook', terminal: true, by: 'notify' },+  { sel: '#chatHookClear', label: 'setup: remove the webhook', terminal: true, by: 'notify' },+  { sel: '#shareMode', label: 'setup: who can open the plan', terminal: true, by: 'share' },+  { sel: '#shareOrg', label: 'setup: which organization', terminal: true, by: 'share' },   { sel: '#bEffortSet', label: 'batch: set the effort on all', terminal: true, by: 'effort' },   { sel: '.bt-x', label: 'batch: remove a tag from all', terminal: true, by: 'row tags', key: c => c.ds.tag, pin: ['long-lead'] },   { sel: '#legendSecs', label: 'legend: fold or unfold the section chips', safe: true, twice: true },@@ -3214,8 +3224,16 @@ async function notifyFlow(browser) {   let c = await card();   check('with no webhook, the card says so and its controls are off', c.state === 'not set up' && c.disabled && c.test && /No space is set up/.test(c.status), JSON.stringify(c));   const wr = await fetch(X.url + 'notify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '1.2.3.4' }, body: JSON.stringify({ webhook: `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T` }) });-  check('a request that is not from this machine cannot set the webhook', wr.status === 403, String(wr.status));-  await post({ webhook: `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T` });+  check('a request that is not from this machine, and not the owner, cannot set the webhook', wr.status === 403, String(wr.status));+  // The owner's field (0.68.0). On this machine the page counts as the operator, so it shows.+  const hookRow = await page.evaluate(() => ({ shown: document.getElementById('chatHookRow').style.display !== 'none', clear: document.getElementById('chatHookClear').style.display }));+  check('the webhook field shows for the owner, without a remove button while nothing is set', hookRow.shown && hookRow.clear === 'none', JSON.stringify(hookRow));+  await page.click('#chatHook'); await page.type('#chatHook', 'https://example.com/not-a-webhook'); await page.click('#chatHookSave'); await settle(page, 400);+  c = await card();+  check('a URL that is not a Chat webhook is refused from the field, and the card stays not set up', c.state === 'not set up' && /not set|was not set/.test(await page.evaluate(() => document.getElementById('saveInd').textContent)), JSON.stringify(c));+  await page.click('#chatHook', { clickCount: 3 }); await page.keyboard.press('Backspace');+  await page.type('#chatHook', `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T`); await page.keyboard.press('Enter'); await settle(page, 500);+  check('the webhook is set from the field and the field is emptied: the URL is never shown again', (await page.$eval('#chatHook', e => e.value)) === '' && (await page.evaluate(() => document.getElementById('chatHookClear').style.display)) !== 'none');   await view(page, 'gantt'); await view(page, 'setup'); await settle(page, 400);   c = await card();   const leaks = await page.evaluate(() => /UITEST\/messages|key=K|token=T/.test(document.getElementById('suChat').outerHTML + document.body.innerText));@@ -3232,8 +3250,48 @@ async function notifyFlow(browser) {   await page.keyboard.type('Kai'); await page.keyboard.press('Enter');   const t0 = Date.now(); while (Date.now() - t0 < 8000 && posts.length < 2) await sleep(200);   check('an edit in the app arrives as a digest a moment after the plan goes quiet', posts.length === 2 && /reassigned "Notify one" from Drew to Kai/.test(posts[1].text), JSON.stringify(posts[1]));+  // Removing the webhook takes two clicks, and then the card is back to not set up.+  await view(page, 'setup'); await settle(page, 400);+  await page.click('#chatHookClear'); await settle(page, 200);+  check('one click on Remove webhook only arms it', (await (await fetch(X.url + 'notify')).json()).configured === true && /Confirm/.test(await page.$eval('#chatHookClear', e => e.textContent)));+  await page.click('#chatHookClear'); await settle(page, 500);+  c = await card();+  check('the second click removes it', (await (await fetch(X.url + 'notify')).json()).configured === false && c.state === 'not set up', JSON.stringify(c));   await page.close(); fake.close(); }+// The Sharing card (0.68.0). Under --no-auth the page is the operator, so it may change the+// setting; the card says that sign-in is off. The gate itself is proven server-side+// against a fake Adom identity service (test/server_test.mjs).+async function shareFlow(browser) {+  section = 'share';+  const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew' });+  const X = await startServer({ project: { title: 'Share Test', appName: 'share-test', view: { mode: 'setup' } },+    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }], items: [T('s1', 'Share one', 0)] }, 'share');+  const page = await openPage(browser, X.url);+  await view(page, 'setup'); await settle(page, 400);+  const card = () => page.evaluate(() => ({ state: document.getElementById('shareState').textContent, status: document.getElementById('shareStatus').textContent,+    mode: document.getElementById('shareMode').value, disabled: document.getElementById('shareMode').disabled, org: document.getElementById('shareOrg').style.display !== 'none', orgVal: document.getElementById('shareOrg').value }));+  let c = await card();+  check('a fresh plan is open to anyone signed in to Adom, and the card says sign-in is off here', c.state === 'anyone signed in to Adom' && c.mode === 'any' && !c.org && /Sign-in is off/.test(c.status) && /No owner is named/.test(c.status), JSON.stringify(c));+  await page.select('#shareMode', 'me'); await settle(page, 500);+  c = await card();+  const acc = await (await fetch(X.url + 'access')).json();+  check('picking Only the owner sets it on the server and the card', acc.mode === 'me' && c.state === 'only the owner' && !c.org, JSON.stringify([acc, c]));+  await page.select('#shareMode', 'org'); await settle(page, 500);+  c = await card();+  const acc2 = await (await fetch(X.url + 'access')).json();+  check('picking an organization shows the org list and shares with adom', acc2.mode === 'org' && acc2.org === 'adom' && c.org && c.orgVal === 'adom' && c.state === 'adom members', JSON.stringify([acc2, c]));+  const md = await (await fetch(X.url + 'changelog?format=md')).text();+  check('each change is in the changelog', md.includes('shared the plan with its owner only (was anyone signed in to Adom)') && md.includes('shared the plan with the adom organization (was its owner only)'), md.split('\n').filter(l => /shared/.test(l)).join(' | '));+  const keys = await page.evaluate(() => { const s = document.getElementById('shareMode'); s.focus(); return s.value; });+  await page.keyboard.press('ArrowDown'); await settle(page, 200);+  await page.keyboard.press('ArrowDown'); await settle(page, 200);+  const mid = await (await fetch(X.url + 'access')).json();+  await page.keyboard.press('Enter'); await settle(page, 500);+  const fin = await (await fetch(X.url + 'access')).json();+  check('the arrow keys walk the list without changing the setting; Enter applies', keys === 'org' && mid.mode === 'org' && fin.mode === 'any', JSON.stringify([keys, mid.mode, fin.mode]));+  await page.close();+}  function cleanupSync() {   if (cleanedUp) return; cleanedUp = true;