app
Adom Gantt
Public Made by Adomby adom
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, push
← Commit history
0.21.0: sign in with Adom, real names on the history, and the Delete key
The plan is about to be shared with the team, so it needed a door and names on
the history.
AUTH. Every page load and every change requires a signed-in Adom account, via
Authentication Intents. The live API is not the one in the skill and was measured
rather than assumed: there is no confirmation_code, there is an auth_url. Both are
now commented above the implementation. Sessions are ours rather than Carbon's:
once the identity is read the Carbon token is not needed, so what persists is a
local id, the profile and an expiry at mode 0600, and a restart does not sign the
team out.
The hard part was telling the container apart from the internet. The workspace
proxy runs inside this container and forwards to 127.0.0.1, so remoteAddress
cannot answer it. Measured: a proxied request always carries x-forwarded-*, cf-*
and a public Host; a direct one carries none. So direct-loopback GETs of /data,
/state, /changelog and /export/* stay open and keep the AI handoff loop working,
and everything that changes the plan needs a session from any origin. The code
says out loud that this is not a security boundary.
IDENTITY. Per the identity-card spec, not this app's taste: compressed chip of
avatar plus real name in the header, medium card on click with the email shown
rather than hidden behind a hover, then Sign out. The changelog actor comes from
the session and never from the body, proven by posting actor:IMPOSTOR and getting
Drew Owens. New items default their provenance to 'Added by <your name>'.
MIGRATION. 90 'Added by hand' labels became 'Added by Drew Owens', the string the
app now generates for him, resolved from the identity API. Run inside the restart
window the server.js change required anyway, so a live tab could not save over it.
The transform refuses to write unless every differing path ends in /provenance or
/prov: 90 differing paths, zero others. 115 items before and after, nothing lost.
One loud changelog line with the count.
MULTI-USER. Whole-state saves are still last-write-wins and this does not fix it.
It adds the cheap half: a stateRev on the sidecar, a baseRev on each save, and a
409 when the file moved, at which point that tab stops autosaving and offers a
reload instead of overwriting someone. Fails open, so a client with no baseRev
saves as it always did. A real merge was not attempted: telling 'my teammate just
created this row' from 'I just deleted it' needs provenance the sidecar does not
carry.
DELETE key on the focused row, same path as the row button, one undo and one redo,
focus advances so a cleanup run is Delete, Delete, Delete. Backspace not bound.
Inert in all eight editors, the create panel, the search box, mid-drag and the
chart. Also drops the 'from your last new item' hint per feedback.
Fingerprint moves only in the version and a uniform 119px shift of the three
right-hand header controls, which is the identity chip's width.
7 files changed
+565−21
README.md+37@@ -121,6 +121,42 @@ Create a JSON file with three sections: Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. +## Sharing a plan with your team++Every page load and every change requires a signed-in Adom account. An unauthenticated+visitor gets a sign-in page and nothing else: no plan data is sent before the session+exists. Sign-in uses Authentication Intents ("Sign in with Adom"), the session lives in+an HttpOnly cookie for 30 days, and it survives a server restart.++Your identity does three things once you are signed in. The header shows who you are+signed in as, and clicking it gives you your details and a way to sign out. A new task+defaults its provenance to "Added by <your name>", still editable. And the changelog+records what you did under your real name, so a shared plan's history reads+"Drew Owens moved X" rather than "user moved X". The actor is taken from the session+and never from the request body, so it cannot be spoofed.++**Reading the plan from inside the container is still open.** A GET of `/data`,+`/state`, `/changelog` or `/export/*` over direct loopback (127.0.0.1, no proxy+headers) needs no session, which is what keeps an AI agent's read-and-hand-off loop+working. Anything that CHANGES the plan needs a signed-in person no matter where it+came from. This is not a security boundary and does not pretend to be one: anything+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.++### Known limitation: two people editing at once++The save model is whole-state, and it is **last-write-wins**. If two teammates have+the plan open and both edit, the second save overwrites the first, and the only record+of what was lost is the changelog. There is no merge and no operational transform.++What the app does do is refuse to be the one that erases you silently. Every save+carries the revision it was based on; if the plan moved since you loaded it, the save+is refused with a 409, your tab stops autosaving so it cannot clobber anything, and+you are told, with a Reload button. Your own edits stay on screen until you decide.+The practical advice is unchanged: for now, edit one at a time, or reload before a+long editing run.+ ## Keyboard shortcuts | Key | Action |@@ -129,6 +165,7 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between | Ctrl+Shift+Z | Redo, also Ctrl+Y. Replays only what undo took back, so any new edit clears it | | Up / Down | Move the selected row in the table, following the visible order under sorts and filters. They never scroll the pane by themselves; it moves only enough to keep the selected row in view | | Enter | Edit the selected row's name. Inside a cell, commit and step down one row |+| Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound | | Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N | | Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane. Under a column sort the row stays inside the run of rows the sort considers tied with it | | Tab / Shift+Tab | In the table, commit the open cell editor and open the next one, ready to type. Wraps across rows and around the ends, and follows the visible order under a sort |
SKILL.md+6@@ -194,6 +194,12 @@ rather than a category. - **Click legend** to filter phases, milestones, dependency arrows, or the today line - **Click a bar** to open the date editor with start/end pickers and dependency management - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded+- **Sign in with Adom is required** for every page load and every change. Unauthenticated visitors get a sign-in page and no plan data. Sessions are an HttpOnly cookie, 30 days, and survive a restart. `--no-auth` turns it off for single-user local use+- **Direct-loopback GETs of `/data`, `/state`, `/changelog` and `/export/*` need no session**, so an AI agent's read loop keeps working; everything that changes the plan needs a signed-in person from any origin. A request is "direct loopback" only if it comes from 127.0.0.1 with no `x-forwarded-*`/`cf-*` headers and a loopback `Host`, because the workspace proxy also arrives from 127.0.0.1+- **The changelog actor is the signed-in identity**, taken from the session and never from the request body, so a shared plan's history reads "Drew Owens moved X"+- **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field+- **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart+- **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person - **Ctrl+Z** to undo (100-level stack) - **Ctrl+Shift+Z** (or **Ctrl+Y**) to redo. Redo only ever replays what undo just took back, so any new edit clears it. It covers every undoable action symmetrically: field edits, snapped drags, row and lane moves, deletes, creates (the row comes back with the same id and stable key), milestone edits and the whole-plan shift. Inside a text field the field's own undo and redo win, exactly as Ctrl+Z already does. Redo is logged to the changelog as its own event, tagged `redo` - **Up / Down** in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone
docs/BUILD-NOTES.md+85@@ -3,6 +3,91 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-11 (0.21.0, sign in with Adom, and who did what)++The plan is about to be shared with the team, so it needed a door and it needed+names on the history.++**The live intents API is not the one in the skill, and it was measured rather than+assumed.** `POST /auth/intents` returns no `confirmation_code`; it returns an+`auth_url` to send the browser to. Everything else (the token, the status endpoint,+the `authenticated` state carrying a `session_token`) is as documented. Both facts+are now in a comment above the implementation so the next person does not have to+rediscover them. The whole flow is verifiable headless in this container:+`PATCH /auth/intents/{token}` with the container api-key and an empty body is what a+browser's approval does, after which the status endpoint hands over the session+token. That is how the end-to-end sign-in below was proven without a human browser.++**The hard part was not auth, it was telling the container apart from the internet.**+The workspace proxy runs inside this same container and forwards to 127.0.0.1, so a+browser on the public URL arrives from loopback exactly like a curl does, and+`remoteAddress` cannot answer the question at all. Measured against the real proxy,+the two are easy to separate: a proxied request always carries `x-forwarded-*`,+`cf-*` and a public `Host`, and a direct one carries none of them and a loopback+`Host`. So the rule is direct-loopback GETs of the read-only endpoints stay open, and+everything that changes the plan needs a session from any origin. That keeps the AI+handoff loop reading `/state` and `/changelog` after the door is locked. It is not a+security boundary and the code says so out loud: anything already on loopback in this+container can read the api-key and the data file, so there is nothing to escalate to.++**The session is ours, not Carbon's.** Once the identity has been read once, this app+has no further use for the Carbon token, and a bearer token written to disk beside a+project file is a credential at rest that nothing here needs. What persists is a+local id, the profile it resolved to, and an expiry, mode 0600, so a restart does not+sign the whole team out.++**Identity renders per the identity-card spec**, not per this app's taste: the+compressed chip in the header is avatar plus real name, and clicking it gives the+medium card, real name bold and primary, `@handle · email` on one muted secondary+line with the email shown rather than hidden behind a hover, then Sign out. Monochrome+icon, no emoji, one teal. The chip has a fixed maximum width so it cannot move+anything as a name changes length, and it sits last in the header, which does move+the text-size control's absolute offset for the first time since 0.8.7. The invariant+0.8.7 actually bought was that the control never moves WHILE you use the app, and a+fixed-width chip after it keeps that.++**Attribution is taken from the session and never from the body.** Verified by+posting `actor: "IMPOSTOR"` with a valid session: the log recorded "Drew Owens". An+append-only log that can be told who to blame is worse than one with no names in it.++**The migration.** 90 provenance labels reading "Added by hand" became "Added by Drew+Owens", the same string the app now generates for him, resolved from the identity API+rather than guessed: 5 in the data file, 40 created rows in the sidecar, and 45+sidecar overrides, which shadow the data file and would otherwise have kept saying the+old thing. Done inside the server restart window that the server.js change required+anyway, so a live tab could not autosave over it. The transform refuses to write+unless a structural diff of both files shows that every differing path ends in+`/provenance` or `/prov`: it found 90 differing paths and zero others. Merged plan+before and after: 115 items, nothing lost, 45 differing paths, all provenance. One+loud changelog line records it with the count and says exactly what was not touched.+Rows whose provenance came from the plan of record or a chat thread keep what they had.++**Two people editing at once, stated plainly rather than solved.** The save model is+whole-state and last-write-wins, and this round does not fix that. What it adds is the+cheap half: a `stateRev` on the sidecar, a `baseRev` on each save, and a 409 when the+file moved since the client loaded it, at which point that tab stops autosaving and+offers a reload instead of overwriting someone. It fails open by design, so a client+that sends no `baseRev` saves exactly as it always did and this can never take+autosave away from anyone if the bookkeeping is ever wrong. A real merge was+deliberately not attempted: distinguishing "a row my teammate just created" from "a+row I just deleted" needs per-row provenance the sidecar does not carry, and guessing+wrong resurrects deleted rows.++**Delete on the focused row**, same path as the row's own button, so one undo, one+toast naming the row, one changelog event and one redo. Focus moves to the row that+slid up, so a cleanup run is Delete, Delete, Delete. Backspace is deliberately not+bound. Verified inert inside all eight editor types, the create panel, the search box,+mid-drag and in the chart.++**Two things worth carrying forward.** The snapshot exporter finds the state-fetch+block by exact-text regex, and the conflict work changed that line; the anchor was+re-pointed and all four snapshot anchors are now asserted against the current+gantt.html as part of the build. And for the second time in two rounds a scratch+server was pointed at a port already held by an unrelated app in this container+(8951, then 8961) and the harness happily scraped the stranger's page. Checking that+something answers is not the check; checking that the port was FREE before binding,+and that the page served back is ours, is.+ ## Build notes, 2026-08-11 (0.20.0, redo) Ctrl+Shift+Z, and Ctrl+Y as its Windows alias. A minor rather than a patch: a redo
gantt.html+132−6@@ -154,6 +154,8 @@ header #btnTheme { min-width: 56px; justify-content: center; } already shows the span), and the "starts" label goes (its button says a date). With the labels gone the buttons are all one width, so nothing shifts. */ @media (max-width: 1460px) {+ .who-name { display: none; }+ .who { max-width: none; } header .subject-meta, header .start-ctl label, header .act-btn .btn-label,@@ -500,6 +502,26 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .col-grip::after { content: ''; position: absolute; inset: 4px 3px; background: transparent; transition: background 0.15s; } .col-grip:hover::after, .col-grip.dragging::after { background: var(--accent); } body.col-sizing { cursor: col-resize; user-select: none; }++/* The Adom identity card, compressed rendering: avatar plus real name. Fixed+ maximum width so the chip can never move anything else in the header as a name+ changes length. Monochrome, no emoji, one teal accent, per the identity-card+ and brand rules. */+.who { display: flex; align-items: center; gap: 7px; padding: 3px 8px 3px 3px; border-radius: 999px;+ border: 1px solid transparent; cursor: pointer; max-width: 190px; flex-shrink: 0; }+.who:hover { border-color: var(--border); background: var(--surface2); }+.who-av { width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0; object-fit: cover; display: block;+ background: linear-gradient(135deg, var(--accent) 0%, #5b7fd4 100%); color: #05201f;+ font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 700; line-height: 24px;+ text-align: center; letter-spacing: 0.02em; }+.who-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 500; color: var(--text2);+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.who-card { padding: 12px 14px; display: flex; gap: 11px; align-items: flex-start; border-bottom: 1px solid var(--border); }+.who-card .who-av { width: 34px; height: 34px; font-size: 13px; line-height: 34px; }+.who-card-txt { min-width: 0; }+.who-real { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--text);+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.who-sub { font-size: 11px; color: var(--text2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* The grid is fixed from the first paint, so every cell clips at its column and nothing in the body can reach a width. .fixed-cols now only means "the user has set widths of their own", which is what switches the table from the proportional@@ -738,6 +760,12 @@ const SNAPSHOT = false; // the released version. Opening this file straight off disk leaves the token in // place, which is why it is checked before being shown. const RAW_VERSION = '__APP_VERSION__';+// The signed-in identity, substituted by the server from the session the same way+// the version is. Opening this file straight off disk leaves the placeholder, which+// is a string, so it is shape-checked rather than trusted.+const RAW_USER = "__ADOM_USER__";+const ADOM_USER = (RAW_USER && typeof RAW_USER === 'object') ? RAW_USER : null;+const USER_NAME = ADOM_USER ? (ADOM_USER.displayName || ADOM_USER.username || '') : ''; const APP_VERSION = /^[0-9]/.test(RAW_VERSION) ? RAW_VERSION : ''; // project.view is the author's intended default view. It applies everywhere the chart@@ -1019,7 +1047,11 @@ $('hdr').innerHTML = ` <button class="fs-btn" id="fsDown" title="Smaller text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="17" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="14" font-weight="600">A</text></svg></button> <button class="fs-pct" id="fsPct" title="Text size. Click to go back to 100%"></button> <button class="fs-btn fs-big" id="fsUp" title="Larger text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="19" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="20" font-weight="600">A</text></svg></button>- </div>+ </div>${ADOM_USER?`+ <div class="vsep"></div>+ <div class="who" id="whoChip" title="Signed in as ${esc(ADOM_USER.displayName)}${ADOM_USER.username?', @'+esc(ADOM_USER.username):''}${ADOM_USER.email?', '+esc(ADOM_USER.email):''}">+ ${avatarHtml(ADOM_USER,'who-av')}<span class="who-name">${esc(ADOM_USER.displayName)}</span>+ </div>`:''} </div>`; applyView(); // again, now that the toggle buttons exist }@@ -1030,6 +1062,13 @@ applyView(); // again, now that the toggle buttons exist // banding and the group header instead, so a repeat three rows down is not confusing. // Returning the CSS var (not a resolved hex) is what lets a theme switch recolour // every bar without rebuilding the chart.+// The avatar is the identity, and it never renders as a blank circle: no url, or a+// url that fails to load, falls back to initials on the brand gradient.+function avatarHtml(u,cls){+ const ini=esc(u.initials||'?');+ if(u.avatarUrl)return `<img class="${cls}" src="${esc(u.avatarUrl)}" alt="" referrerpolicy="no-referrer" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'${cls}',textContent:'${ini}'}))">`;+ return `<span class="${cls}">${ini}</span>`;+} function phaseColor(i){ const p = phases[i]; return (p && p.color) ? p.color : `var(--ph${(i % 3) + 1})`;@@ -1436,6 +1475,27 @@ document.addEventListener('keydown',e=>{ moveRowFocus(e.key==='ArrowDown'?1:-1); return; }+ // Delete removes the focused row, the same path the row's own Delete button+ // takes, so it is one undo, one toast naming the row, one changelog event and one+ // sidecar deletion signal. Focus moves to the row that slid up into its place so+ // a cleanup run is Delete, Delete, Delete. Backspace is deliberately NOT bound:+ // it is a fat-finger away from Delete and browsers spent a decade navigating back+ // on it. Table only, and only when a row is selected and no field is live, which+ // is the same gate the arrows use, so Delete inside an editor only edits text.+ if(e.key==='Delete'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){+ if(!tableNavReady())return;+ const rows=visibleRowEls();+ const i=rows.findIndex(t=>t.dataset.key===focusedKey);+ if(i<0)return; // nothing selected, nothing to delete+ const r=rowItem(focusedKey);+ if(!r)return;+ e.preventDefault();+ deleteRow(r);+ // The list is one shorter now, so the same index is the row that moved up, and+ // the clamp inside focusRowAt lands on the last row when the last one went.+ focusRowAt(Math.min(i,visibleRowEls().length-1));+ return;+ } // Enter on the focused row edits its name, which is the field you almost always // want and the one the chart's label click already opens. if(e.key==='Enter'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){@@ -1475,6 +1535,12 @@ document.addEventListener('keydown',e=>{ // ── Persistence ── let saveTimer=null;+// Which revision of the sidecar this tab is working from. The server bumps it on+// every save and refuses a save whose base is stale, so the second writer is told+// rather than the first writer being erased. Null means "we have not seen one", and+// the check is skipped, which is exactly how an older build behaves.+let baseRev=null;+let saveBlocked=false; // Milestones carry no id, so an array of tasks alone loses their dates on reload and // they snap back to the data file while the tasks stay shifted. Persist both. // An empty value means "no value", not the empty string, so clearing an owner in@@ -1727,7 +1793,34 @@ document.addEventListener('visibilitychange',()=>{if(document.visibilityState=== // A snapshot is a file, not a server. The export used to try to rewrite this // function with a regex that could never match its own source; the flag it // already sets is enough.-function saveToFile(){if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getState())}).then(r=>r.json()).then(()=>{flash(saveInd,'saved');}).catch(()=>{flash(saveInd,'save failed',2500);});}+function saveToFile(){+ if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}+ if(saveBlocked)return;+ const payload=getState();+ if(typeof baseRev==='number')payload.baseRev=baseRev;+ fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})+ .then(async r=>{+ const d=await r.json().catch(()=>({}));+ if(r.status===409){ onSaveConflict(d); return; }+ if(typeof d.stateRev==='number')baseRev=d.stateRev;+ flash(saveInd,'saved');+ })+ .catch(()=>{flash(saveInd,'save failed',2500);});+}+// Someone else wrote to this plan while we were editing it. We do not merge and we+// do not overwrite them: autosave stops so this tab cannot clobber anything, and the+// user is told plainly, with the way out one click away. Their own edits are still+// on screen, so nothing is taken from them without their say-so.+function onSaveConflict(d){+ saveBlocked=true;+ clearTimeout(undoTimer);+ undoToast.innerHTML='Someone else changed this plan. Your edits are not being saved. '++ '<button type="button" id="reloadPlan" class="de-btn primary" style="margin-left:8px;padding:2px 10px">Reload</button>';+ undoToast.classList.add('show');+ const b=$('reloadPlan');+ if(b)b.addEventListener('click',()=>location.reload());+ flash(saveInd,'not saved, plan changed elsewhere',5000);+} function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),ms||1500);} // ── Legend ──@@ -2748,6 +2841,7 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Shift+Z</span>Redo, also Ctrl+Y. Any new edit clears it</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected row. Undoable</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Alt+Up/Down</span>Move the row one slot. Under a sort it stays inside its tied group</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Tab</span>Commit and open the next field. Shift+Tab goes back</div>@@ -2936,7 +3030,9 @@ function openCreateEditor(){ const ownerVal=memo.owner||''; const startVal=memo.start||today; const endVal=memo.end||plus;- const carried=lastCreated?'<span class="de-hint">from your last new item</span>':'';+ // Who added it, by name, when there is a name to use. Still an ordinary editable+ // field: this is a default, not a stamp.+ const provVal=USER_NAME?('Added by '+USER_NAME):'Added by hand'; editorEl.innerHTML=`<div class="drag-bar"></div> <div class="batch-title">New item</div> <div class="de-row">@@ -2945,7 +3041,7 @@ function openCreateEditor(){ </div> <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="ncName" placeholder="What is it?"></div> <div class="de-row" style="margin-top:8px">- <div style="flex:1"><label>owner ${carried}</label><input type="text" id="ncOwner" list="ownerList" placeholder="one name" value="${esc(ownerVal)}"></div>+ <div style="flex:1"><label>owner</label><input type="text" id="ncOwner" list="ownerList" placeholder="one name" value="${esc(ownerVal)}"></div> <div style="flex:1"><label>status</label><select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div> </div> <div class="de-row">@@ -2953,7 +3049,7 @@ function openCreateEditor(){ <div><label id="ncEndLabel">deadline</label><input type="date" id="ncEnd" value="${endVal}"></div> </div> <div class="de-note-row"><label>notes</label><input type="text" id="ncDesc" placeholder="Shown on hover and in the table"></div>- <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="Added by hand"></div>+ <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="${esc(provVal)}"></div> <div class="de-actions"><button class="de-btn primary" id="ncCreate">Create</button> <button class="de-btn secondary" id="ncCancel">Cancel</button></div> <div id="ncErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;@@ -3003,6 +3099,35 @@ function openCreateEditor(){ flash(saveInd,'added '+(isMs?'milestone':'task')); }); }+// Compressed chip in the header, medium card in the pulldown: the same two+// renderings every other Adom surface shows, so identity is one learnable object+// rather than a per-app invention. Real name bold and primary, the two machine+// identifiers together on one muted line, email shown and not hidden behind a+// hover. No orgs at this size, and no kebab: the card is display, the rows below+// are the actions.+function openWhoMenu(){+ const chip=$('whoChip'); if(!chip||!ADOM_USER)return;+ closeMenus();+ const r=chip.getBoundingClientRect();+ exportMenu.innerHTML=`<div class="who-card">${avatarHtml(ADOM_USER,'who-av')}+ <div class="who-card-txt">+ <div class="who-real">${esc(ADOM_USER.displayName)}</div>+ <div class="who-sub">${ADOM_USER.username?'@'+esc(ADOM_USER.username):''}${ADOM_USER.username&&ADOM_USER.email?' · ':''}${esc(ADOM_USER.email||'')}</div>+ </div></div>+ <div class="dd-item" id="whoOut"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>Sign out</div>`;+ exportMenu.style.left=Math.min(r.right-230,window.innerWidth-250)+'px';+ exportMenu.style.top=(r.bottom+6)+'px';+ exportMenu.classList.add('show');+ const out=$('whoOut');+ if(out)out.addEventListener('click',async()=>{+ try{ await fetch('auth/signout',{method:'POST'}); }catch(e){}+ location.reload();+ });+}+function wireWhoChip(){+const chip=$('whoChip');+if(chip)chip.addEventListener('click',e=>{e.stopPropagation();openWhoMenu();});+} function wireNewButton(){ $('btnNew').addEventListener('click',e=>{e.stopPropagation();closeMenus();openCreateEditor();}); }@@ -4076,7 +4201,7 @@ async function startApp(){ $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);}); // ── Init ──-try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){applyState(st);Object.keys(barEls).forEach(id=>updateBar(id));}}}catch(e){}+try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;applyState(st);Object.keys(barEls).forEach(id=>updateBar(id));}else baseRev=0;}}catch(e){} // The sidecar can carry a status or a description that the chart's own label rows // and tooltips were built from the data file with, so restate them. items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });@@ -4164,6 +4289,7 @@ async function init(){ wireImportAndHelp(); wireThemeButton(); wireMsStyleAndGrain();+ wireWhoChip(); wireNewButton(); wireFontScale(); wireFitButton();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.20.0",+ "version": "0.21.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.20.0",+ "version": "0.21.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}
server.js+302−12@@ -3,6 +3,7 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const url_ = require('url');+const crypto = require('crypto'); const args = process.argv.slice(2); const dataIdx = args.indexOf('--data');@@ -22,6 +23,188 @@ if (!fs.existsSync(DATA_FILE)) { const STATE_FILE = DATA_FILE.replace(/\.json$/, '-state.json'); const LOG_FILE = DATA_FILE.replace(/\.json$/, '-changelog.jsonl'); const APP_DIR = __dirname;++// ── Sign in with Adom ──+//+// Authentication Intents, per the 3rd-party-auth skill. The live API differs from+// that document in two ways worth writing down, because both were measured against+// carbon.adom.inc rather than assumed: there is NO `confirmation_code` in the+// response, and the intent carries an `auth_url` to send the browser to instead.+// The rest of the shape (token, expires_at, updates_url, the status endpoint and+// its `authenticated` state with a `session_token`) is as documented.+const CARBON = process.env.ADOM_CARBON_API || 'https://carbon.adom.inc';+const AUTH_OFF = args.includes('--no-auth');+const SESSION_FILE = DATA_FILE ? DATA_FILE.replace(/\.json$/, '-sessions.json') : null;+const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;+const COOKIE = 'adom_gantt_sid';++// Our own session, not Carbon's. Deliberately: once the identity has been read+// once, this app has no further need of the Carbon token, and a bearer token+// written to disk beside a project file is a credential at rest that nothing here+// needs. What is persisted is the local id, the profile it resolved to, and an+// expiry, so a server restart does not sign the whole team out.+const sessions = new Map();+function loadSessions() {+ try {+ const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));+ const now = Date.now();+ for (const [sid, s] of Object.entries(raw || {})) if (s && s.exp > now) sessions.set(sid, s);+ } catch (e) {}+}+function saveSessions() {+ try {+ const now = Date.now(), o = {};+ for (const [sid, s] of sessions) if (s.exp > now) o[sid] = s;+ fs.writeFileSync(SESSION_FILE, JSON.stringify(o), 'utf8');+ fs.chmodSync(SESSION_FILE, 0o600);+ } catch (e) {}+}+function newSession(user) {+ const sid = crypto.randomBytes(24).toString('base64url');+ sessions.set(sid, { user, exp: Date.now() + SESSION_TTL_MS });+ saveSessions();+ return sid;+}+function sessionOf(req) {+ const raw = req.headers.cookie || '';+ const hit = raw.split(';').map(x => x.trim()).find(x => x.startsWith(COOKIE + '='));+ if (!hit) return null;+ const sid = decodeURIComponent(hit.slice(COOKIE.length + 1));+ const s = sessions.get(sid);+ if (!s) return null;+ if (s.exp <= Date.now()) { sessions.delete(sid); saveSessions(); return null; }+ return { sid, user: s.user };+}+function setCookie(req, res, sid) {+ const https = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https';+ const bits = [COOKIE + '=' + encodeURIComponent(sid), 'Path=/', 'HttpOnly', 'SameSite=Lax',+ 'Max-Age=' + Math.floor(SESSION_TTL_MS / 1000)];+ if (https) bits.push('Secure');+ res.setHeader('Set-Cookie', bits.join('; '));+}++// Is this the container talking to itself, or is it someone out on the internet?+// The socket alone cannot answer it: the workspace proxy runs inside this same+// container and forwards to 127.0.0.1, so a browser on the public URL arrives from+// loopback exactly like a curl does. Measured, the two are still easy to tell+// apart: a proxied request always carries x-forwarded-* and cf-* and a public Host,+// and a direct one carries none of them and a loopback Host.+//+// This is not a security boundary and is not pretending to be one. Anything already+// running on loopback in this container can read the api-key and the data file+// directly, so there is nothing here for it to escalate to. It exists so the AI+// handoff loop, which reads /state and /changelog over 127.0.0.1, keeps working+// after the door is locked to the internet.+const FWD_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip',+ 'cf-connecting-ip', 'cf-ray', 'forwarded'];+function isDirectLoopback(req) {+ 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;+ const host = String(req.headers.host || '').replace(/^\[|\]$/g, '').split(':')[0];+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';+}++// Read-only, and only over direct loopback. Everything that CHANGES the plan needs+// 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']);+function authFor(req, urlPath) {+ if (AUTH_OFF) return { ok: true, user: null, mode: 'off' };+ const sess = sessionOf(req);+ if (sess) return { ok: true, user: sess.user, sid: sess.sid, mode: 'session' };+ if (req.method === 'GET' && LOOPBACK_READS.has(urlPath) && isDirectLoopback(req)) {+ return { ok: true, user: null, mode: 'loopback' };+ }+ return { ok: false };+}++async function carbonUser(token) {+ const r = await fetch(CARBON + '/user', { headers: { Authorization: 'Bearer ' + token } });+ if (!r.ok) throw new Error('carbon /user ' + r.status);+ const u = await r.json();+ const displayName = u.display_name || u.formatted_name || u.name || u.email || 'Someone';+ return {+ id: u.id, username: u.name || '', displayName,+ email: u.email || '', avatarUrl: u.avatar_url || '',+ initials: displayName.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?',+ };+}++function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }++function signInPage() {+ return `<!DOCTYPE html>+<html lang="en"><head><meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1.0">+<title>Sign in to adom-gantt</title>+<link rel="icon" href="favicon.svg">+<style>+:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#e6edf3;--text2:#8b949e;--accent:#00b8b1;--accent-hover:#00d4cb}+*{box-sizing:border-box}+body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--text);+ font-family:'Familjen Grotesk',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}+.card{width:min(420px,92vw);background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:32px 30px}+.brand{display:flex;align-items:center;gap:10px;margin-bottom:22px}+.brand svg{width:22px;height:22px;color:var(--accent)}+.brand span{font-size:15px;font-weight:600;letter-spacing:-0.01em}+h1{margin:0 0 8px;font-size:19px;font-weight:600;letter-spacing:-0.01em}+p{margin:0 0 22px;font-size:13px;line-height:1.55;color:var(--text2)}+button{width:100%;padding:11px 16px;font-family:inherit;font-size:13px;font-weight:600;color:#05201f;+ background:var(--accent);border:none;border-radius:7px;cursor:pointer;transition:background .15s}+button:hover{background:var(--accent-hover)}+button:disabled{opacity:.6;cursor:default}+.step{margin-top:20px;font-size:12px;line-height:1.6;color:var(--text2);display:none}+.step.show{display:block}+.step a{color:var(--accent);word-break:break-all}+.err{margin-top:16px;font-size:12px;color:#f85149;display:none}+.err.show{display:block}+.foot{margin-top:24px;padding-top:16px;border-top:1px solid var(--border);font-size:11px;color:var(--text2)}+.spin{display:inline-block;width:11px;height:11px;border:2px solid var(--text2);border-top-color:transparent;+ border-radius:50%;animation:sp .7s linear infinite;vertical-align:-1px;margin-right:6px}+@keyframes sp{to{transform:rotate(360deg)}}+</style></head><body>+<div class="card">+ <div class="brand">+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>+ <span>adom-gantt</span>+ </div>+ <h1>Sign in to see this plan</h1>+ <p>This plan is shared with your team. Sign in with your Adom account to open it, and your edits will be recorded under your name.</p>+ <button id="go">Sign in with Adom</button>+ <div class="step" id="step"><span class="spin"></span>Waiting for you to approve in the other tab. <a id="link" href="#" target="_blank" rel="noopener">Open it again</a></div>+ <div class="err" id="err"></div>+ <div class="foot">Nothing on this page is loaded until you are signed in.</div>+</div>+<script>+const btn=document.getElementById('go'),step=document.getElementById('step'),err=document.getElementById('err'),link=document.getElementById('link');+let timer=null;+function fail(m){err.textContent=m;err.classList.add('show');btn.disabled=false;step.classList.remove('show');}+btn.addEventListener('click',async()=>{+ err.classList.remove('show');btn.disabled=true;+ try{+ const r=await fetch('auth/start',{method:'POST'});+ if(!r.ok)throw new Error('could not start sign-in');+ const d=await r.json();+ link.href=d.auth_url;+ window.open(d.auth_url,'_blank','noopener');+ step.classList.add('show');+ const t0=Date.now();+ timer=setInterval(async()=>{+ if(Date.now()-t0>15*60*1000){clearInterval(timer);fail('That sign-in request expired. Try again.');return;}+ try{+ const p=await fetch('auth/poll?token='+encodeURIComponent(d.token));+ const s=await p.json();+ if(s.ok){clearInterval(timer);location.reload();}+ else if(s.state==='expired'){clearInterval(timer);fail('That sign-in request expired. Try again.');}+ }catch(e){}+ },2000);+ }catch(e){fail(e.message||'Sign-in could not start.');}+});+</script>+</body></html>`;+}+ // Read from our own manifest per request, not once at boot. gantt.html is already // read per request, so a deploy that only changes the HTML needs no restart; if // the version were cached at startup that deploy would leave the header showing@@ -31,8 +214,15 @@ function appVersion() { try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || ''; } catch { return ''; } }-function serveApp() {- return fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8').split('__APP_VERSION__').join(appVersion());+function serveApp(user) {+ // The identity goes in the same way the version does, substituted on the way out+ // of a placeholder that is a valid JS string when the file is opened off disk.+ // "<" is escaped for the same reason the HTML snapshot escapes it: a display name+ // containing "</script>" would otherwise close this tag.+ const who = JSON.stringify(user || null).replace(/</g, '\\u003c');+ return fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8')+ .split('__APP_VERSION__').join(appVersion())+ .split('"__ADOM_USER__"').join(who); } const DATA_DIR = path.dirname(DATA_FILE); @@ -102,6 +292,12 @@ function readLog() { .filter(Boolean); } +// A counter on the sidecar, bumped by every successful save. It answers exactly one+// question: has this file changed since the copy I am holding?+function stateRev() {+ try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')).stateRev || 0; }+ catch (e) { return 0; }+} function currentRev() { const log = readLog(); return log.length ? log[log.length - 1].rev : 0;@@ -274,6 +470,11 @@ function describeEvent(e) { if (e.hint) return `${who} moved ${e.hint} to a different position in the plan${via}`; return `${who} reordered rows: ${(Array.isArray(e.moved) ? e.moved : []).join(', ') || 'row order changed'}${via}`; }+ // A one-off data migration. Loud on purpose: it changed rows without anyone+ // clicking anything, so the log has to say so in one readable line.+ if (action === 'migration') {+ return `${who} ran a data migration: ${oneLine(e.what || 'changed rows')}. ${e.count || 0} value(s) updated${e.detail ? '. ' + oneLine(e.detail) : ''}${via}`;+ } if (action === 'import') { return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`; }@@ -1064,8 +1265,8 @@ function generateSVG() { // ── Export: HTML snapshot ── -function generateHTMLSnapshot() {- const ganttHtml = serveApp();+function generateHTMLSnapshot(user) {+ const ganttHtml = serveApp(user); const data = mergedData(); // Replace the fetch('data') call with inline data.@@ -1091,7 +1292,7 @@ function generateHTMLSnapshot() { // Replace the state fetch block html = html.replace(- /try\{const r=await fetch\('state'\);if\(r\.ok\)\{const st=await r\.json\(\);if\(!st\.empty\)\{applyState\(st\);Object\.keys\(barEls\)\.forEach\(id=>updateBar\(id\)\);\}\}\}catch\(e\)\{\}/,+ /try\{const r=await fetch\('state'\);if\(r\.ok\)\{const st=await r\.json\(\);if\(!st\.empty\)\{baseRev=typeof st\.stateRev==='number'\?st\.stateRev:0;applyState\(st\);Object\.keys\(barEls\)\.forEach\(id=>updateBar\(id\)\);\}else baseRev=0;\}\}catch\(e\)\{\}/, '/* state already merged into inline data */' ); @@ -1136,6 +1337,69 @@ const server = http.createServer(async (req, res) => { try { + // ── Sign in with Adom ──++ if (req.method === 'POST' && urlPath === '/auth/start') {+ const r = await fetch(CARBON + '/auth/intents', {+ method: 'POST', headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ max_age: Math.floor(SESSION_TTL_MS / 1000) }),+ });+ if (!r.ok) { jsonResp(res, 502, { ok: false, error: 'could not reach Adom sign-in' }); return; }+ const intent = await r.json();+ jsonResp(res, 200, { token: intent.token, auth_url: intent.auth_url });+ return;+ }+ if (urlPath === '/auth/poll') {+ const token = parsed.query.token;+ if (!token) { jsonResp(res, 400, { ok: false, error: 'token required' }); return; }+ const r = await fetch(CARBON + '/auth/intents/' + encodeURIComponent(token) + '/status');+ if (r.status === 410) { jsonResp(res, 200, { state: 'expired' }); return; }+ if (!r.ok) { jsonResp(res, 200, { state: 'pending' }); return; }+ const st = await r.json();+ if (st.state !== 'authenticated' || !st.session_token) { jsonResp(res, 200, { state: st.state || 'pending' }); return; }+ let user;+ try { user = await carbonUser(st.session_token); }+ catch (e) { jsonResp(res, 502, { ok: false, error: 'could not read your Adom profile' }); return; }+ const sid = newSession(user);+ setCookie(req, res, sid);+ jsonResp(res, 200, { ok: true, user });+ return;+ }+ if (urlPath === '/auth/me') {+ const sess = sessionOf(req);+ jsonResp(res, 200, { signedIn: !!sess, user: sess ? sess.user : null, authRequired: !AUTH_OFF });+ return;+ }+ if (req.method === 'POST' && urlPath === '/auth/signout') {+ const sess = sessionOf(req);+ if (sess) { sessions.delete(sess.sid); saveSessions(); }+ res.setHeader('Set-Cookie', COOKIE + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');+ jsonResp(res, 200, { ok: true });+ return;+ }++ // ── The door ──+ //+ // Everything below this line is either the plan or something that changes it.+ // An unauthenticated browser gets the sign-in page and nothing else; an+ // unauthenticated API call gets a 401 and nothing else. No data goes out first.+ const auth = authFor(req, urlPath);+ if (!auth.ok) {+ if (urlPath === '/favicon.svg') {+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });+ res.end(fs.readFileSync(path.join(APP_DIR, 'favicon.svg')));+ return;+ }+ const wantsHtml = String(req.headers.accept || '').includes('text/html');+ 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 with your Adom account to use this plan', signIn: '/' });+ return;+ }+ // ── Existing endpoints ── if (urlPath === '/data') {@@ -1147,9 +1411,26 @@ const server = http.createServer(async (req, res) => { if (req.method === 'POST' && urlPath === '/save') { const body = await readBody(req); try {- JSON.parse(body);- fs.writeFileSync(STATE_FILE, body, 'utf8');- jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length });+ const incoming = JSON.parse(body);+ // Two people editing at once still write the whole state, and the last one+ // wins. This does NOT fix that, and nothing here pretends to merge. What it+ // does is refuse to be the one that silently erases someone: a client that+ // knows which revision it is working from is told when the file moved under+ // it, instead of writing over the change it never saw.+ //+ // Fails open on purpose. A client that sends no baseRev (an older build, a+ // snapshot, a script) saves exactly as it always did, so this cannot take+ // autosave away from anyone if the bookkeeping is ever wrong.+ const cur = stateRev();+ if (typeof incoming.baseRev === 'number' && incoming.baseRev !== cur) {+ jsonResp(res, 409, { ok: false, conflict: true, stateRev: cur, yourRev: incoming.baseRev,+ error: 'someone else changed this plan since you loaded it' });+ return;+ }+ incoming.stateRev = cur + 1;+ delete incoming.baseRev;+ fs.writeFileSync(STATE_FILE, JSON.stringify(incoming), 'utf8');+ jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length, stateRev: incoming.stateRev }); } catch (e) { jsonResp(res, 400, { ok: false, error: e.message }); }@@ -1183,7 +1464,11 @@ const server = http.createServer(async (req, res) => { return; } if (!events.length) { jsonResp(res, 200, { ok: true, added: 0, rev: currentRev() }); return; }- const out = appendLog(events, payload && payload.actor);+ // The actor is taken from the session, never from the body. A client that+ // says it is someone else is simply wrong, and an append-only log that can+ // be told who to blame is worse than one with no names in it at all.+ const who = auth.user ? (auth.user.displayName || auth.user.username) : null;+ const out = appendLog(events, who || (payload && payload.actor)); jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) }); } catch (e) { jsonResp(res, 400, { ok: false, error: e.message });@@ -1334,7 +1619,7 @@ const server = http.createServer(async (req, res) => { } if (urlPath === '/export/html') {- const html = generateHTMLSnapshot();+ const html = generateHTMLSnapshot(auth.user); res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Disposition': 'attachment; filename="adom-gantt-snapshot.html"'@@ -1346,8 +1631,8 @@ const server = http.createServer(async (req, res) => { // ── Static file serving ── if (urlPath === '/' || urlPath === '/gantt.html') {- res.writeHead(200, { 'Content-Type': 'text/html' });- res.end(serveApp());+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });+ res.end(serveApp(auth.user)); return; } @@ -1372,9 +1657,14 @@ process.on('uncaughtException', err => { console.error('adom-gantt: uncaught', err && err.stack ? err.stack : err); }); +if (SESSION_FILE) loadSessions();+ server.listen(PORT, '127.0.0.1', () => { console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`); console.log(`State file: ${STATE_FILE}`); console.log(`Data dir: ${DATA_DIR}`); console.log(`Endpoints: /data /state /save /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ 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.'); });