phase: 121_git_source_tokens
**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:
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user