phase: 121_git_source_tokens
Build and Push Containers / build-and-push-app (push) Successful in 2m3s
Build and Push Containers / build-and-push-db (push) Failing after 14s

**Phase 121 final verification pass — all green** (all 4 tasks already in `complete/`; verified, no defects found, no changes needed)

- Verified implementation vs phase design: migration `0021` (reversible, round-tripped via `alembic downgrade base` + `upgrade head` → head `0021`), `GitSource.token` column, `normalize_credential`/`clone_url_for`/`sanitize_url`, clone callers switched (`sync.py`, `import_docs.py`), masked token fields in add form + editor, `extra="forbid"` output shapes
- Tests: `uv run pytest` → 2662 passed, 0 failed (exit 0); `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (≥90% gate)
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- E2E in isolation: `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → **4 passed**

Completion criteria:
1. Private repo (UI add or pasted embedded-token URL) clones with injected token; token absent from every API response, page text, title attr, and full HTML — **PASS** (integration raw-JSON assertions + E2E `_assert_token_nowhere`)
2. Legacy embedded-token rows still clone from stored URL; output sanitized — **PASS** (`test_sync_legacy_row_clones_with_original_stored_url`, `test_get_masks_legacy_embedded_token_row`, env-fallback masking)
3. Public/local sources byte-identical — **PASS** (verbatim-URL + no-userinfo-unchanged tests)
4. pytest / coverage / ruff / pyright — **PASS** (see above)
5. Commit + phase move — harness responsibility; task files already in `complete/`, changes left in working tree (no commit made, per protocol)

Notable: no deviations; DB left at head, functional. Next pending phase: **122_image_documents** (then 123_chat_image_questions).
This commit is contained in:
2026-09-24 20:51:39 -04:00
parent 3a0fc3db05
commit 0f77e9a876
35 changed files with 2894 additions and 48 deletions
+46 -4
View File
@@ -203,6 +203,25 @@
* calls hideHiddenError — the phase-89 "happy path heals the error
* state" precedent).
*
* Phase 121 (task 03) — the masked token field (LOCKED A2): the add
* form gains the optional `#git-source-token` (type=password,
* autocomplete=off — a PAT is not a site credential) with the visible
* "optional — private repos" hint; the submit body is
* `(url, token) => ({ url, ...(token ? { token } : {}) })` — a blank
* token OMITS the key (None = no credential), and 201 clears BOTH
* inputs (the credential is stored — write-only: the API shapes
* carry no token field, so nothing round-trips). The per-row editor
* (the ignore-paths dialog) mirrors it: `#ignore-editor-token` with
* the placeholder "leave blank to keep the current token" — it
* always opens BLANK (there is no token field to prefill from) and
* the PATCH body includes `token` ONLY when non-blank (the tri-state:
* absent = no change, the row's stored credential is kept). Every
* display site keeps rendering `s.url` UNCHANGED — the server now
* returns bare URLs (sanitize_url), so the list cell, its title
* attribute, the remove modal, and the editor's source line are
* token-free with no per-site change; the UI must never re-embed a
* credential.
*
* Scope boundary (phase locked decisions): adding a git repo does
* NOT clone — the sync service (server-side) does that. Removing a
* source, however, performs the FULL cleanup server-side (phase 69):
@@ -239,6 +258,9 @@ export async function mount(root) {
const contentEl = root.querySelector("#git-sources-content");
const formEl = root.querySelector("#git-source-form");
const urlInput = root.querySelector("#git-source-url");
/* Phase 121: the add form's optional masked token field — blank =
no credential (the key is omitted from the POST body). */
const tokenInput = root.querySelector("#git-source-token");
const addBtn = root.querySelector("#git-source-add");
const addError = root.querySelector("#git-source-error");
/* Phase 49: the archive upload form (replaces the phase-38 local
@@ -275,6 +297,9 @@ export async function mount(root) {
const ignoreBackdrop = root.querySelector(".ignore-editor-backdrop");
const ignoreSourceEl = root.querySelector("#ignore-editor-source");
const ignoreTextarea = root.querySelector("#ignore-editor-textarea");
/* Phase 121: the editor's masked token field — blank = keep the
current token (the PATCH omits the key, the tri-state no-change). */
const ignoreTokenInput = root.querySelector("#ignore-editor-token");
const ignoreErrorEl = root.querySelector("#ignore-editor-error");
const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel");
const ignoreSaveBtn = root.querySelector("#ignore-editor-save");
@@ -392,6 +417,7 @@ export async function mount(root) {
if (s.id) tr.dataset.id = s.id;
const isLocal = s.kind === "local";
// Phase 121: URLs arrive sanitized server-side — the UI must never re-embed a credential.
const value = isLocal ? (s.path ?? s.url) : s.url;
const kindLabel = isLocal ? "local" : "git";
@@ -696,6 +722,10 @@ export async function mount(root) {
// The same `value` expression makeRow uses — textContent ONLY.
if (ignoreSourceEl) ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url;
ignoreTextarea.value = (s.ignore_paths || []).join("\n");
// Phase 121: the token field always opens BLANK — the API has no
// token field to prefill from (LOCKED A2); blank = the PATCH
// omits the key (no change — the stored token is kept).
if (ignoreTokenInput) ignoreTokenInput.value = "";
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true; // a new attempt starts clean
@@ -719,6 +749,7 @@ export async function mount(root) {
ignoreDialog.hidden = true;
ignoreInFlight = false;
if (ignoreTextarea) ignoreTextarea.value = "";
if (ignoreTokenInput) ignoreTokenInput.value = ""; // phase 121: re-opens blank
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true;
@@ -759,6 +790,10 @@ export async function mount(root) {
// separator, not an entry (trim + drop empty; the server still
// rejects empties defensively, A4).
const lines = ignoreTextarea.value.split("\n").map((l) => l.trim()).filter(Boolean);
// Phase 121: the masked token — BLANK = the key is omitted from
// the PATCH (the tri-state: absent = no change, the row's stored
// credential is kept); non-blank replaces it.
const token = ignoreTokenInput ? ignoreTokenInput.value.trim() : "";
const t = ignoreTarget;
const value = t.kind === "local" ? (t.path ?? t.url) : t.url;
ignoreInFlight = true;
@@ -775,7 +810,7 @@ export async function mount(root) {
const r = await fetch(`/api/git-sources/${encodeURIComponent(t.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ignore_paths: lines }),
body: JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) }),
});
if (r.ok) {
// 200: the server replaced the row's list (A5).
@@ -882,7 +917,7 @@ export async function mount(root) {
* 409/422 details are fixed generic strings (credential safety — the
* URL is never echoed). */
function wireAddForm(opts) {
const { form, input, btn, error } = opts;
const { form, input, btn, error, tokenInput } = opts;
if (!form || !input || !btn) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -900,10 +935,13 @@ export async function mount(root) {
btn.disabled = true; // §7.4: one POST per click
btn.textContent = "Adding…";
try {
// Phase 121: the masked token rides the same POST — blank =
// the key is omitted (None = no credential; LOCKED A2).
const token = tokenInput ? tokenInput.value.trim() : "";
const r = await fetch("/api/git-sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts.body(value)),
body: JSON.stringify(opts.body(value, token)),
});
if (r.ok) {
let createdId = null;
@@ -913,6 +951,7 @@ export async function mount(root) {
/* the 201 body is advisory — the reload is the truth */
}
input.value = ""; // 201: the source is stored
if (tokenInput) tokenInput.value = ""; // the credential is stored (write-only)
announce(opts.addedMessage);
await loadSources(); // the new row lands in the table
focusNewRow(createdId); // a11y: land the caret on the new row
@@ -940,9 +979,12 @@ export async function mount(root) {
wireAddForm({
form: formEl,
input: urlInput,
tokenInput,
btn: addBtn,
error: addError,
body: (url) => ({ url }),
// Phase 121: the masked token is included ONLY when non-blank
// (blank = key omitted = no credential — the API's tri-state).
body: (url, token) => ({ url, ...(token ? { token } : {}) }),
emptyMessage: "Enter a git URL to add.",
failMessage: "Could not add the git source — try again.",
networkMessage: "Could not add the git source — is the app reachable?",
+35 -3
View File
@@ -2386,7 +2386,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#archive-upload-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
#git-source-form > label,
#archive-upload-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
#git-source-url {
/* Phase 121 (task 03): the form-label "optional" hint — the muted
ink-soft pair (5.1:1 on the label's surface, AA) at the label's
600 weight relaxed to 400 so the hint reads as secondary (it
qualifies the name, it is not the name); small, inline. */
.field-hint { color: var(--ink-soft); font-weight: 400; font-size: 0.8rem; }
/* #git-source-token (phase 121): the optional masked private-repo
credential — the URL input's treatment VERBATIM (mono, >=44px
target, brand focus); type=password masks the value in display. */
#git-source-url,
#git-source-token {
flex: 1;
min-width: 14rem;
min-height: 44px;
@@ -2398,8 +2407,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border-radius: var(--radius-sm);
padding: 0.45rem 0.7rem;
}
#git-source-url::placeholder { color: var(--ink-soft); }
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
#git-source-url::placeholder,
#git-source-token::placeholder { color: var(--ink-soft); }
#git-source-url:focus-visible,
#git-source-token:focus-visible { outline-offset: 0; border-color: var(--brand); }
#git-source-add,
#archive-upload-btn {
display: inline-flex;
@@ -2802,6 +2813,26 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
resize: vertical;
}
/* Phase 121 (task 03): the editor's masked token field — the
textarea's box language (full panel width, --bg fill, the line
border, mono) at the 44px touch floor; the label's top margin keeps
the field pair off the textarea. type=password + autocomplete=off
live in the markup (a PAT is not a site credential — no browser
save offer); the focus ring is the global 3px outline rule. */
.ignore-editor-token-label { margin-top: 0.9rem; }
.ignore-editor-token {
display: block;
width: 100%;
min-height: 44px;
padding: 0.55rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
/* The in-dialog failure line (role=alert): the err pair (err-ink on
err-bg 9.3:1, the err-line border) — the .remove-confirm-error
language. */
@@ -4943,6 +4974,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#git-source-form > label,
#archive-upload-form > label { white-space: normal; }
#git-source-url,
#git-source-token,
#archive-upload-file { min-width: 0; }
#git-source-add,
#archive-upload-btn { width: 100%; }
+37 -5
View File
@@ -625,10 +625,11 @@
to the database.
</p>
<!-- Add form: visible label + mono URL input + brand button
(dark ink on brand 5.2:1). §7.4 never-stale: the button
disables + relabels "Adding…" while the POST is in flight
and re-enables on success AND failure (the input is kept
<!-- Add form: visible label + mono URL input + the optional
masked token field (phase 121) + brand button (dark ink
on brand 5.2:1). §7.4 never-stale: the button disables +
relabels "Adding…" while the POST is in flight and
re-enables on success AND failure (the inputs are kept
on failure, same as the tuning forms). -->
<form id="git-source-form">
<label for="git-source-url">Add a git source</label>
@@ -641,6 +642,22 @@
placeholder="https://github.com/you/your-repo.git"
required
>
<!-- Phase 121 (task 03, LOCKED A2): the optional masked
private-repo credential — type=password +
autocomplete=off (a PAT is not a site credential: no
browser save offer). Blank = the POST omits the key
(None = no credential); the token is write-only — it
never round-trips (the API shapes carry no token
field). -->
<label for="git-source-token">Token <span class="field-hint">optional — private repos</span></label>
<input
id="git-source-token"
name="token"
type="password"
maxlength="500"
autocomplete="off"
placeholder="ghp_… or another PAT"
>
<button type="submit" id="git-source-add">Add source</button>
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
</form>
@@ -784,12 +801,16 @@
while the PATCH is out). Stored rows only (A3) — the
per-row button lives in git-sources.js's makeRow; this
is the page-local dialog it opens. -->
<!-- Phase 121 (task 03): the per-row editor gains the
optional masked token field (LOCKED A2) — the title
widens from "Ignored files and folders" to the dialog's
actual scope (the row's settings). -->
<div class="ignore-editor" id="ignore-editor-dialog" role="alertdialog"
aria-modal="true" aria-labelledby="ignore-editor-title"
aria-describedby="ignore-editor-copy" hidden>
<div class="ignore-editor-backdrop" aria-hidden="true"></div>
<div class="ignore-editor-panel">
<h2 class="ignore-editor-title" id="ignore-editor-title">Ignored files and folders</h2>
<h2 class="ignore-editor-title" id="ignore-editor-title">Source settings</h2>
<code class="ignore-editor-source" id="ignore-editor-source"></code>
<p class="ignore-editor-copy" id="ignore-editor-copy">
One path per line. A file is ignored when its path in the
@@ -801,6 +822,17 @@
<textarea class="ignore-editor-textarea" id="ignore-editor-textarea"
rows="6" spellcheck="false"
placeholder="my/files/"></textarea>
<!-- Phase 121 (task 03, LOCKED A2): the masked
private-repo credential — type=password +
autocomplete=off (a PAT is not a site credential).
BLANK = the PATCH omits the key (the tri-state: no
change — the row's stored token is kept). The field
is NEVER prefilled (the API has no token field to
read it from). -->
<label class="ignore-editor-label ignore-editor-token-label" for="ignore-editor-token">Token <span class="field-hint">optional — private repos</span></label>
<input class="ignore-editor-token" id="ignore-editor-token"
type="password" maxlength="500" autocomplete="off"
placeholder="leave blank to keep the current token">
<p class="ignore-editor-error" id="ignore-editor-error" role="alert" hidden></p>
<div class="ignore-editor-actions">
<button type="button" class="ignore-editor-btn ignore-editor-cancel"