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?",
|
||||
|
||||
Reference in New Issue
Block a user