Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
163bfad30a | ||
|
|
23c8e57a8e | ||
|
|
350bfc067d | ||
|
|
98aecd8255 | ||
|
|
7d4ff1f095 | ||
|
|
3605c42651 | ||
|
|
d7e2813700 | ||
|
|
fb34b5ffda | ||
|
|
820da7130d | ||
|
|
3bad8a0198 | ||
|
|
8bc5f623ac | ||
|
|
175817e6d8 | ||
|
|
fff7bccf08 | ||
|
|
ee36b8824a | ||
|
|
2c8a1225e0 |
No files matched your search
@@ -178,6 +178,11 @@ export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Public passkey ceremonies share the same window and durable source ledger.
|
||||
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
|
||||
# Bound live one-time challenges even if anonymous clients rotate addresses.
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
@@ -185,13 +190,16 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Sign-in failures are scoped to a hashed canonical client address and persisted across
|
||||
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
||||
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
||||
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
||||
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||
that setting, a reverse proxy is safely treated as one shared source.
|
||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||
source and globally in the session registry. Once either sign-in budget is exhausted,
|
||||
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
||||
failure state. Expired source records and challenges are pruned, and both ledgers are
|
||||
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
|
||||
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
|
||||
networks you operate. Without that setting, a reverse proxy is safely treated as one
|
||||
shared source.
|
||||
|
||||
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
||||
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||
|
||||
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -91,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
...(message.kind === 'issue-blocker' ? {
|
||||
blockerRepository: String(message.blockerRepository || ''),
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -163,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
@@ -212,6 +220,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else if (item.kind === 'issue-blocker') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present,
|
||||
}),
|
||||
});
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
|
||||
@@ -368,6 +368,24 @@ function createBackgroundIssueSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present === true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -612,6 +630,16 @@ function createBackgroundIssueSync({
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
|
||||
const error = new Error('Blocker change was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
||||
@@ -30,6 +30,8 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
@@ -151,6 +153,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
@@ -265,6 +273,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
@@ -278,6 +287,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
|
||||
.issue-blocker-row .issue-blocker { min-width:0; }
|
||||
.issue-blocker-remove { min-height:44px; white-space:normal; }
|
||||
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
|
||||
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
|
||||
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
@@ -453,6 +470,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
@@ -468,6 +486,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
|
||||
+280
-33
@@ -123,6 +123,8 @@
|
||||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let issueBlockerCandidates = [];
|
||||
let issueBlockerSearchTimer = null;
|
||||
let issueConversation = null;
|
||||
let issueTrigger = null;
|
||||
let selectedPull = null;
|
||||
@@ -267,11 +269,8 @@
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const wrapPreference = createReviewController.createWrapPreference({
|
||||
storage: localStorage,
|
||||
mobile: window.matchMedia('(max-width: 600px)').matches,
|
||||
});
|
||||
let reviewController = null;
|
||||
let wrapPreference = null;
|
||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueAttachmentController = issueAttachment.mount({
|
||||
input: qs('#issue-attachment'),
|
||||
@@ -364,7 +363,7 @@
|
||||
loadLabels: item => issueController.loadLabels(item),
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
let issueCapture = null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
storage: localStorage,
|
||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||
@@ -393,6 +392,9 @@
|
||||
backgroundSync: backgroundIssueSync,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
const notificationReadOutbox = createNotificationReadOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
backgroundSync: backgroundIssueSync,
|
||||
@@ -411,9 +413,49 @@
|
||||
text: shareParams.get('text') || '',
|
||||
url: shareParams.get('url') || '',
|
||||
};
|
||||
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueCaptureFeatures = createFeatureLoader({
|
||||
document,
|
||||
urls: {
|
||||
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
||||
},
|
||||
});
|
||||
let sharedLaunchState = null;
|
||||
let sharedLaunchHandled = false;
|
||||
async function ensureIssueCapture() {
|
||||
return await issueCaptureFeatures.run('issue-capture', {
|
||||
trigger: qs('#new-issue'), status: qs('#my-work-action-status'),
|
||||
}, () => {
|
||||
if (!issueCapture) {
|
||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
}
|
||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||||
sharedLaunchHandled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture();
|
||||
const pullWorkflowFeatures = createFeatureLoader({
|
||||
document,
|
||||
urls: {
|
||||
'pull-workflow': document.querySelector('meta[name="stackchain-feature-pull-workflow"]')?.content || '',
|
||||
},
|
||||
});
|
||||
let pullController = null;
|
||||
async function ensurePullWorkflow(trigger = null) {
|
||||
return await pullWorkflowFeatures.run('pull-workflow', {
|
||||
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
|
||||
}, () => {
|
||||
if (!pullController) pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
storage: localStorage,
|
||||
mobile: window.matchMedia('(max-width: 600px)').matches,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
@@ -431,8 +473,13 @@
|
||||
retry.hidden = status.failed === 0;
|
||||
}
|
||||
const offlineToday = createOfflineToday({
|
||||
loadDetail: item => item.is_review ? reviewController.load(item) :
|
||||
item.kind === 'pull' ? pullController.load(item) : issueController.load(item),
|
||||
loadDetail: async item => {
|
||||
if (item.is_review || item.kind === 'pull') {
|
||||
if (!await ensurePullWorkflow()) throw new Error('Pull workspace is unavailable.');
|
||||
return item.is_review ? reviewController.load(item) : pullController.load(item);
|
||||
}
|
||||
return issueController.load(item);
|
||||
},
|
||||
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
|
||||
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
|
||||
onStatus: renderOfflineTodayStatus,
|
||||
@@ -483,6 +530,14 @@
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function acknowledgeNotification(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||||
'/acknowledge', { method: 'POST', headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Acknowledging the update failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function markNotificationsRead(ids) {
|
||||
const response = await fetch('api/v1/notifications/read', {
|
||||
method: 'PATCH',
|
||||
@@ -561,6 +616,13 @@
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const notificationSelection = createNotificationSelection({
|
||||
limit: 50,
|
||||
onChange: () => {
|
||||
bulkConfirmationPending = false;
|
||||
renderMyWork();
|
||||
},
|
||||
});
|
||||
const notificationPager = createNotificationPager({
|
||||
load: fetchNotificationPage,
|
||||
onNotifications: items => {
|
||||
@@ -649,6 +711,7 @@
|
||||
load: fetchNotificationDetail,
|
||||
loadConversation: fetchNotificationConversation,
|
||||
markRead: markNotificationRead,
|
||||
acknowledge: acknowledgeNotification,
|
||||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||||
onOpen: item => {
|
||||
@@ -671,6 +734,7 @@
|
||||
qs('#update-reply-status').textContent = '';
|
||||
qs('#send-update-reply').disabled = false;
|
||||
qs('#send-update-reply-read-next').disabled = false;
|
||||
qs('#acknowledge-update-next').hidden = true;
|
||||
qs('#update-ownership-action').hidden = true;
|
||||
qs('#update-ownership-start').hidden = true;
|
||||
qs('#retry-update-load').hidden = true;
|
||||
@@ -683,6 +747,7 @@
|
||||
qs('#update-subject-state').textContent = detail.state || '';
|
||||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
||||
if (offlineWorkMode) {
|
||||
setOfflineUpdateControls(true);
|
||||
} else {
|
||||
@@ -1332,13 +1397,42 @@
|
||||
|
||||
function renderPlanIssueDependencies(detail, loading = false) {
|
||||
const preview = planTodayPreview.snapshot();
|
||||
if (!preview.open || preview.item?.kind !== 'issue') return;
|
||||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||||
const panel = qs('#issue-blockers');
|
||||
const list = qs('#issue-blocker-list');
|
||||
const status = qs('#issue-blocker-status');
|
||||
const addButton = qs('#add-plan-preview');
|
||||
panel.hidden = false;
|
||||
list.innerHTML = '';
|
||||
qs('#manage-issue-blockers').hidden = !assignedIssue;
|
||||
qs('#start-unblocked-issue').hidden = true;
|
||||
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
||||
if (assignedIssue) {
|
||||
const available = detail?.dependencies_available === true;
|
||||
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
|
||||
list.innerHTML = dependencies.map((blocker, index) =>
|
||||
'<div class="issue-blocker-row"><a class="issue-blocker" href="' + escAttr(blocker.url || '#') +
|
||||
'" target="_blank" rel="noopener noreferrer"><strong>' + escapeHtml(blocker.repository + '#' + blocker.number) +
|
||||
' · ' + escapeHtml(blocker.title || 'Untitled blocker') + '</strong><span class="small">State: ' +
|
||||
escapeHtml(blocker.state || 'open') + '</span></a><button class="issue-blocker-remove" type="button" data-remove-blocker-index="' +
|
||||
index + '">Remove blocker</button></div>'
|
||||
).join('');
|
||||
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
|
||||
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
|
||||
mutateIssueBlocker(blocker, true, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
status.textContent = available ? (dependencies.length ?
|
||||
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
|
||||
'No unresolved blockers. Add one without leaving this issue.') :
|
||||
'Blocker status unavailable. Reload before changing relationships.';
|
||||
qs('#manage-issue-blockers').disabled = !available;
|
||||
return;
|
||||
}
|
||||
addButton.disabled = loading;
|
||||
addButton.dataset.planOverride = '';
|
||||
if (loading) {
|
||||
@@ -1371,6 +1465,111 @@
|
||||
addButton.textContent = 'Add to Today & back';
|
||||
}
|
||||
|
||||
async function mutateIssueBlocker(blocker, remove, button) {
|
||||
if (!selectedIssue) return;
|
||||
const item = selectedIssue;
|
||||
button.disabled = true;
|
||||
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
||||
try {
|
||||
if (selectedIssueOffline) {
|
||||
await queueOfflineIssueBlocker(item, blocker, !remove);
|
||||
if (selectedIssue !== item) return;
|
||||
button.textContent = 'Queued';
|
||||
qs('#issue-blocker-status').textContent = remove ?
|
||||
'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
|
||||
'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
|
||||
return { queued:true };
|
||||
}
|
||||
const result = remove ?
|
||||
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
||||
await issueController.updateBlocker(selectedIssue, blocker, false);
|
||||
if (selectedIssue !== item) return;
|
||||
selectedIssueDetail = { ...selectedIssueDetail, ...result };
|
||||
renderPlanIssueDependencies(selectedIssueDetail);
|
||||
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
|
||||
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
|
||||
qs('#start-unblocked-issue').hidden = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderIssueBlockerCandidates(items) {
|
||||
issueBlockerCandidates = items;
|
||||
const results = qs('#issue-blocker-results');
|
||||
results.innerHTML = items.map((result, index) =>
|
||||
'<button type="button" role="option" data-blocker-result="' + index + '"><strong>' +
|
||||
escapeHtml(result.repository + '#' + result.number) + '</strong><span class="small">' +
|
||||
escapeHtml(result.title || 'Untitled issue') + '</span></button>'
|
||||
).join('');
|
||||
results.querySelectorAll('[data-blocker-result]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
|
||||
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
|
||||
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
|
||||
candidate.repository === blocker.repository && candidate.number === blocker.number
|
||||
)) {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
qs('#manage-issue-blockers').addEventListener('click', () => {
|
||||
qs('#issue-blocker-manager').hidden = false;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
|
||||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||||
qs('#issue-blocker-search').focus();
|
||||
});
|
||||
qs('#cancel-issue-blocker').addEventListener('click', () => {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||||
qs('#manage-issue-blockers').focus();
|
||||
});
|
||||
qs('#issue-blocker-search').addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
|
||||
});
|
||||
qs('#issue-blocker-search').addEventListener('input', event => {
|
||||
clearTimeout(issueBlockerSearchTimer);
|
||||
const query = event.target.value.trim();
|
||||
if (query.length < 2) {
|
||||
renderIssueBlockerCandidates([]);
|
||||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||||
return;
|
||||
}
|
||||
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
|
||||
issueBlockerSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const candidates = selectedIssueOffline ? workSession.items() :
|
||||
(await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
|
||||
const existing = selectedIssueDetail?.dependencies || [];
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const items = (candidates || []).filter(result =>
|
||||
result.kind === 'issue' && result.state === 'open' &&
|
||||
(!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
|
||||
.toLowerCase().includes(normalizedQuery)) &&
|
||||
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
|
||||
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
|
||||
);
|
||||
renderIssueBlockerCandidates(items);
|
||||
qs('#issue-blocker-search-status').textContent = items.length ?
|
||||
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
|
||||
} catch (error) {
|
||||
renderIssueBlockerCandidates([]);
|
||||
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
qs('#start-unblocked-issue').addEventListener('click', () => {
|
||||
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
|
||||
});
|
||||
|
||||
let addPlanPreviewOnReturn = false;
|
||||
let addPlanPreviewOverride = false;
|
||||
|
||||
@@ -1517,6 +1716,7 @@
|
||||
function setOfflineUpdateControls(offline) {
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
||||
qs('#acknowledge-update-next').disabled = offline;
|
||||
qs('#update-ownership-action').disabled = offline;
|
||||
qs('#update-ownership-start').disabled = offline;
|
||||
qs('#load-older-update-comments').disabled = offline;
|
||||
@@ -1893,6 +2093,8 @@
|
||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
const selection = notificationSelection.snapshot();
|
||||
const selectedIds = new Set(selection.ids);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
const routeItem = routedWorkItem(item);
|
||||
@@ -1906,7 +2108,13 @@
|
||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
||||
item.has_update && Number.isInteger(item.notification_id);
|
||||
const selector = selectable ?
|
||||
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '';
|
||||
const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
|
||||
(selectedIds.has(item.notification_id) ? ' selected' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||
@@ -1922,19 +2130,30 @@
|
||||
const planningActions = selectedWorkFilter === 'later' ? laterActions :
|
||||
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||||
if (item.is_review) {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'issue') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'pull') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||
cardPlanning.wire();
|
||||
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
const notificationId = Number(input.dataset.selectNotificationId);
|
||||
const result = input.checked ? notificationSelection.select(notificationId) :
|
||||
notificationSelection.toggle(notificationId);
|
||||
if (result === 'limit') {
|
||||
input.checked = false;
|
||||
qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
@@ -2053,20 +2272,22 @@
|
||||
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
||||
});
|
||||
});
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
const bulkButton = qs('#bulk-mark-read');
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
||||
const updateIds = notificationIds(visible);
|
||||
const selectionControls = qs('#update-selection-controls');
|
||||
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
|
||||
qs('#select-updates').hidden = selection.active;
|
||||
qs('#cancel-update-selection').hidden = !selection.active;
|
||||
qs('#update-selection-status').textContent = selection.active ?
|
||||
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||
bulkButton.disabled = bulkMarkPending;
|
||||
const bulkLabel = allIds.length > ids.length ?
|
||||
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
||||
'all ' + ids.length + ' updates';
|
||||
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
||||
bulkButton.textContent = bulkConfirmationPending ?
|
||||
'Confirm marking ' + bulkLabel + ' read' :
|
||||
'Mark ' + bulkLabel + ' read';
|
||||
'Confirm marking ' + selection.count + ' selected read' :
|
||||
'Mark ' + selection.count + ' selected read';
|
||||
workRoute.setItems(lastMyWork);
|
||||
}
|
||||
|
||||
@@ -2417,6 +2638,7 @@
|
||||
|
||||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();
|
||||
qs('#pull-review').inert = false;
|
||||
selectedPull = item;
|
||||
@@ -2620,6 +2842,7 @@
|
||||
}
|
||||
|
||||
function saveIssueCaptureDraft() {
|
||||
if (!issueCapture) return;
|
||||
issueCapture.saveDraft({
|
||||
repository: qs('#create-issue-repository').value,
|
||||
title: qs('#create-issue-title').value,
|
||||
@@ -2816,7 +3039,8 @@
|
||||
sharedLaunchState = null;
|
||||
}
|
||||
|
||||
function openCreateIssueSheet(navigate = true) {
|
||||
async function openCreateIssueSheet(navigate = true) {
|
||||
if (!issueCapture && !await ensureIssueCapture()) return;
|
||||
if (navigate) {
|
||||
taskOverlayHistory.open('new');
|
||||
return;
|
||||
@@ -2971,6 +3195,7 @@
|
||||
}
|
||||
|
||||
async function openReviewSheet(item, trigger, cachedDetail = null) {
|
||||
if (!item || !await ensurePullWorkflow(trigger)) return;
|
||||
selectedReview = item;
|
||||
reviewTrigger = trigger;
|
||||
offlineReview = Boolean(cachedDetail);
|
||||
@@ -4535,6 +4760,16 @@
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#acknowledge-update-next').addEventListener('click', async () => {
|
||||
const button = qs('#acknowledge-update-next');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
||||
if (result) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.';
|
||||
} finally {
|
||||
button.disabled = offlineWorkMode;
|
||||
}
|
||||
});
|
||||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||
qs('#retry-review-load').addEventListener('click', () => {
|
||||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
@@ -4954,32 +5189,44 @@
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#select-updates').addEventListener('click', () => {
|
||||
notificationSelection.start();
|
||||
qs('#update-selection-status').textContent = '0 of 50 selected';
|
||||
document.querySelector('[data-select-notification-id]')?.focus();
|
||||
});
|
||||
qs('#cancel-update-selection').addEventListener('click', () => {
|
||||
notificationSelection.cancel();
|
||||
qs('#select-updates').focus();
|
||||
});
|
||||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||||
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
||||
const ids = allIds.slice(0, 50);
|
||||
if (!ids.length || bulkMarkPending) return;
|
||||
const selection = notificationSelection.snapshot();
|
||||
if (!selection.ids.length || bulkMarkPending) return;
|
||||
if (!bulkConfirmationPending) {
|
||||
bulkConfirmationPending = true;
|
||||
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
|
||||
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
|
||||
renderMyWork();
|
||||
return;
|
||||
}
|
||||
bulkConfirmationPending = false;
|
||||
bulkMarkPending = true;
|
||||
renderMyWork();
|
||||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
|
||||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
|
||||
if (result) {
|
||||
const marked = new Set(result.marked);
|
||||
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
||||
notificationSelection.retain(result.failed);
|
||||
if (!result.failed.length) notificationSelection.cancel();
|
||||
}
|
||||
bulkMarkPending = false;
|
||||
renderMyWork();
|
||||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||||
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
|
||||
});
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||
button.addEventListener('click', () => {
|
||||
const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
|
||||
selectedWorkFilter = button.dataset.workFilter;
|
||||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||
savedWorkFilter = selectedWorkFilter;
|
||||
launchFilterResolved = true;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
const pending = new Map();
|
||||
const loaded = new Set();
|
||||
|
||||
function load(name) {
|
||||
if (loaded.has(name)) return Promise.resolve();
|
||||
if (pending.has(name)) return pending.get(name);
|
||||
const url = urls[name];
|
||||
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
|
||||
|
||||
const request = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
let timeout;
|
||||
const fail = () => {
|
||||
clearTimeout(timeout);
|
||||
script.remove();
|
||||
pending.delete(name);
|
||||
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
|
||||
};
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
pending.delete(name);
|
||||
loaded.add(name);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = fail;
|
||||
timeout = setTimeout(fail, timeoutMs);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
pending.set(name, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function run(name, elements, callback) {
|
||||
const trigger = elements?.trigger;
|
||||
const status = elements?.status;
|
||||
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
|
||||
if (trigger) trigger.disabled = true;
|
||||
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
|
||||
try {
|
||||
await load(name);
|
||||
callback();
|
||||
if (status) status.textContent = '';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (trigger) trigger.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { load, run, ready: name => loaded.has(name) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
|
||||
@@ -116,6 +116,11 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
@@ -307,6 +312,16 @@
|
||||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
|
||||
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
|
||||
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
|
||||
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
|
||||
<label for="issue-blocker-search">Search open issues</label>
|
||||
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
|
||||
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
|
||||
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
|
||||
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
|
||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
@@ -538,6 +553,7 @@
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
@@ -708,6 +724,7 @@
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
@@ -719,6 +736,7 @@
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/offline-issue-blocker.js"></script>
|
||||
<script src="static/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
|
||||
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
let blockerRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
updateBlocker(item, blocker, remove = false) {
|
||||
if (blockerRequest) return blockerRequest;
|
||||
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
|
||||
method: remove ? 'DELETE' : 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
|
||||
}).then(result => {
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate =>
|
||||
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
|
||||
);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { blockerRequest = null; });
|
||||
return blockerRequest;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
||||
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
ids: Array.from(selected),
|
||||
count: selected.size,
|
||||
limit: maximum,
|
||||
at_limit: selected.size >= maximum,
|
||||
});
|
||||
const publish = () => onChange(snapshot());
|
||||
const select = notificationId => {
|
||||
if (!active || !Number.isInteger(notificationId)) return 'inactive';
|
||||
if (selected.has(notificationId)) return 'already-selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(notificationId);
|
||||
publish();
|
||||
return 'selected';
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (active) return snapshot();
|
||||
active = true;
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
select,
|
||||
toggle(notificationId) {
|
||||
if (!selected.has(notificationId)) return select(notificationId);
|
||||
selected.delete(notificationId);
|
||||
publish();
|
||||
return 'deselected';
|
||||
},
|
||||
retain(notificationIds) {
|
||||
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
|
||||
let changed = false;
|
||||
selected.forEach(id => {
|
||||
if (!allowed.has(id)) {
|
||||
selected.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) publish();
|
||||
return snapshot();
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
@@ -280,6 +336,7 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
acknowledge = null,
|
||||
queueRead = null,
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
@@ -382,6 +439,21 @@ function createNotificationReader({
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
async acknowledgeAndNext(items) {
|
||||
if (!selected || marking || offlineHydrated || !acknowledge) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Adding reaction and marking read…');
|
||||
try {
|
||||
await acknowledge(current.notification_id);
|
||||
return await advanceAfterRead(items, current);
|
||||
} catch (_error) {
|
||||
onStatus('Could not acknowledge update. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -766,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationSelection = createNotificationSelection;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function createOfflineIssueBlocker({
|
||||
enqueueDurably,
|
||||
onQueued = () => {},
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
}) {
|
||||
return async function queueOfflineIssueBlocker(item, blocker, present) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-blocker',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
blockerRepository: String(blocker.repository || ''),
|
||||
blockerNumber: Number(blocker.number || 0),
|
||||
present: present === true,
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
onQueued(item, blocker, present === true, admission);
|
||||
return { admission };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
|
||||
@@ -12,6 +12,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
@@ -22,6 +23,7 @@ const SHELL = [
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
|
||||
@@ -142,6 +142,20 @@
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
@@ -665,6 +679,21 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function formatAuthenticationAlert(alert) {
|
||||
const failed = Math.max(0, Number(alert?.failed_count) || 0);
|
||||
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
|
||||
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
|
||||
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
|
||||
+ (blocked ? ` · ${blocked} blocked` : '');
|
||||
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
|
||||
'en-US', { timeZone: 'UTC' },
|
||||
);
|
||||
return {
|
||||
title,
|
||||
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
@@ -673,6 +702,8 @@
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
authentication_alerts: Array.isArray(payload.authentication_alerts)
|
||||
? payload.authentication_alerts : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
@@ -722,6 +753,7 @@
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
formatAuthenticationAlert,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
|
||||
+47
-11
@@ -13,11 +13,23 @@ import rjsmin
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeBundle:
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
@@ -27,6 +39,7 @@ class FrontendBuild:
|
||||
runtime_name: str
|
||||
shell_digest: str
|
||||
page_sources: tuple[str, ...]
|
||||
feature_bundles: dict[str, RuntimeBundle]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
@@ -41,6 +54,17 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
|
||||
|
||||
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
|
||||
content = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||
source_html = (frontend_dir / "index.html").read_text()
|
||||
@@ -48,13 +72,21 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
|
||||
core_sources = tuple(source for source in sources if source not in feature_source_names)
|
||||
core = _runtime(frontend_dir, core_sources, "runtime")
|
||||
feature_bundles = {
|
||||
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
|
||||
for name, feature_sources in FEATURE_SOURCES.items()
|
||||
}
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
feature_metadata = "\n".join(
|
||||
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
@@ -63,7 +95,9 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
@@ -72,7 +106,8 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
shell_hasher = hashlib.sha256()
|
||||
shell_inputs = (
|
||||
("dashboard.html", dashboard_html.encode()),
|
||||
(runtime_name, runtime),
|
||||
(core.runtime_name, core.runtime_bytes),
|
||||
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
|
||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||
(
|
||||
@@ -105,11 +140,12 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_gzip_bytes=runtime_gzip,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
runtime_bytes=core.runtime_bytes,
|
||||
runtime_gzip_bytes=core.runtime_gzip_bytes,
|
||||
runtime_digest=core.runtime_digest,
|
||||
runtime_name=core.runtime_name,
|
||||
shell_digest=shell_digest,
|
||||
page_sources=sources,
|
||||
feature_bundles=feature_bundles,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
@@ -169,6 +169,10 @@ class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
@@ -731,6 +735,60 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
repository_name = repository.get("full_name")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
|
||||
subject_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
comment_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
|
||||
)
|
||||
if (
|
||||
not subject_match
|
||||
or not comment_match
|
||||
or subject_match.group(1) != repository_name
|
||||
or comment_match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification has no supported latest comment")
|
||||
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Authenticated Gitea user is unavailable")
|
||||
reaction_path = f"/api/v1/{comment_path}/reactions"
|
||||
response = await _get_client().get(reaction_path, headers=_auth())
|
||||
response.raise_for_status()
|
||||
reactions = response.json()
|
||||
existing = any(
|
||||
isinstance(reaction, dict)
|
||||
and reaction.get("content") == "+1"
|
||||
and isinstance(reaction.get("user"), dict)
|
||||
and reaction["user"].get("login") == login
|
||||
for reaction in (reactions if isinstance(reactions, list) else [])
|
||||
)
|
||||
if not existing:
|
||||
response = await _get_client().post(
|
||||
reaction_path, headers=_auth(), json={"content": "+1"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
await mark_notification_read(thread_id)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"reaction": "existing" if existing else "created",
|
||||
"status": "read",
|
||||
}
|
||||
|
||||
|
||||
def _gitea_api_path(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -759,6 +817,9 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
conversation_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
comment_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
|
||||
)
|
||||
repository_name = repository.get("full_name")
|
||||
supported_conversation = (
|
||||
conversation_match
|
||||
@@ -834,6 +895,11 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
"url": latest_url,
|
||||
},
|
||||
"issue": issue,
|
||||
"acknowledge_supported": bool(
|
||||
supported_conversation
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
@@ -1609,6 +1675,73 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
|
||||
return dependencies
|
||||
|
||||
|
||||
async def mutate_assigned_issue_dependency(
|
||||
repository: str,
|
||||
number: int,
|
||||
blocker_repository: str,
|
||||
blocker_number: int,
|
||||
remove: bool = False,
|
||||
) -> dict:
|
||||
"""Add or remove one prerequisite and return the canonical open dependency set."""
|
||||
if (repository, number) == (blocker_repository, blocker_number):
|
||||
raise IssueDependencyInvalidError("an issue cannot block itself")
|
||||
|
||||
login, source = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
source.get("state") != "open"
|
||||
or isinstance(source.get("pull_request"), dict)
|
||||
or not _login_in_users(login, source.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
current = await issue_dependencies(repository, number)
|
||||
currently_present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in current
|
||||
)
|
||||
if currently_present != remove:
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": current,
|
||||
}
|
||||
|
||||
if not remove:
|
||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||
if (
|
||||
not isinstance(candidate, dict)
|
||||
or candidate.get("number") != blocker_number
|
||||
or candidate.get("state") != "open"
|
||||
or isinstance(candidate.get("pull_request"), dict)
|
||||
):
|
||||
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
|
||||
owner, repo = blocker_repository.split("/", 1)
|
||||
response = await _get_client().request(
|
||||
"DELETE" if remove else "POST",
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
json={"owner": owner, "repo": repo, "index": blocker_number},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
confirmed = await issue_dependencies(repository, number)
|
||||
present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in confirmed
|
||||
)
|
||||
if present == remove:
|
||||
raise ValueError("Gitea did not confirm the dependency change")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": confirmed,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
||||
+146
-1
@@ -49,6 +49,9 @@ class LoginAttemptStore:
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
alert_bucket_seconds: int = 60 * 60,
|
||||
alert_retention_seconds: int = 30 * 24 * 60 * 60,
|
||||
max_alert_buckets: int = 720,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
@@ -56,6 +59,9 @@ class LoginAttemptStore:
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
|
||||
self.alert_retention_seconds = max(1, alert_retention_seconds)
|
||||
self.max_alert_buckets = max(1, max_alert_buckets)
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
@@ -74,6 +80,30 @@ class LoginAttemptStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_admissions (
|
||||
bucket TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL,
|
||||
window_started_at REAL NOT NULL,
|
||||
PRIMARY KEY (bucket, source_hash)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_alerts (
|
||||
method TEXT NOT NULL,
|
||||
bucket_started_at INTEGER NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_at INTEGER NOT NULL,
|
||||
last_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (method, bucket_started_at)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
@@ -97,7 +127,81 @@ class LoginAttemptStore:
|
||||
remaining = row[1] + self.window_seconds - now
|
||||
return max(0, math.ceil(remaining))
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
def admit(self, bucket: str, source: str, *, limit: int) -> int:
|
||||
"""Consume one fixed-window admission or return seconds until retry."""
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
limit = max(1, limit)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
|
||||
(self.window_seconds, now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT attempts, window_started_at FROM login_admissions "
|
||||
"WHERE bucket = ? AND source_hash = ?",
|
||||
(bucket, source_hash),
|
||||
).fetchone()
|
||||
if row is not None and row[0] >= limit:
|
||||
return max(1, math.ceil(row[1] + self.window_seconds - now))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
|
||||
(bucket, source_hash, now),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE login_admissions SET attempts = attempts + 1 "
|
||||
"WHERE bucket = ? AND source_hash = ?",
|
||||
(bucket, source_hash),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM login_admissions
|
||||
WHERE (bucket, source_hash) NOT IN (
|
||||
SELECT bucket, source_hash FROM login_admissions
|
||||
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
return 0
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _record_alert(
|
||||
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
|
||||
) -> None:
|
||||
safe_method = method if method in {"token", "passkey"} else "unknown"
|
||||
occurred_at = int(now)
|
||||
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
|
||||
failed = 0 if blocked else 1
|
||||
blocked_count = 1 if blocked else 0
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO login_alerts(
|
||||
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
|
||||
failed_count = failed_count + excluded.failed_count,
|
||||
blocked_count = blocked_count + excluded.blocked_count,
|
||||
first_at = MIN(first_at, excluded.first_at),
|
||||
last_at = MAX(last_at, excluded.last_at)
|
||||
""",
|
||||
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
|
||||
"(SELECT method, bucket_started_at FROM login_alerts "
|
||||
"ORDER BY bucket_started_at DESC LIMIT ?)",
|
||||
(self.max_alert_buckets,),
|
||||
)
|
||||
|
||||
def record_failure(self, source: str, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
@@ -131,11 +235,52 @@ class LoginAttemptStore:
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
self._record_alert(connection, method, blocked=False, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def record_blocked(self, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._record_alert(connection, method, blocked=True, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE last_at < ?",
|
||||
(now - self.alert_retention_seconds,),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT method, failed_count, blocked_count, first_at, last_at "
|
||||
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
|
||||
(bounded_limit,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
return [
|
||||
{
|
||||
"method": row[0],
|
||||
"failed_count": row[1],
|
||||
"blocked_count": row[2],
|
||||
"first_at": row[3],
|
||||
"last_at": row[4],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+204
-2
@@ -249,7 +249,14 @@ def _passkey_store() -> PasskeyStore:
|
||||
database = os.getenv(
|
||||
"STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3")
|
||||
)
|
||||
return PasskeyStore(database, clock=time.time)
|
||||
return PasskeyStore(
|
||||
database,
|
||||
clock=time.time,
|
||||
max_challenges=int(os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES", "10000")),
|
||||
max_challenges_per_source=int(
|
||||
os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE", "10")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _security_event_store() -> SecurityEventStore:
|
||||
@@ -502,6 +509,19 @@ class IssueHandoff(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class IssueBlockerUpdate(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
max_length=255,
|
||||
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||
)
|
||||
number: PositiveInt
|
||||
|
||||
|
||||
class IssueBlockerDesiredState(IssueBlockerUpdate):
|
||||
present: bool
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -927,6 +947,14 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_blocked, method="token")
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{"detail": "Too many sign-in attempts"},
|
||||
status_code=429,
|
||||
@@ -1014,7 +1042,10 @@ async def list_security_events(
|
||||
page = await asyncio.to_thread(
|
||||
_security_event_store().list, limit=limit, cursor=cursor
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
authentication_alerts = await asyncio.to_thread(
|
||||
_login_attempt_store().list_alerts, limit=24
|
||||
)
|
||||
except (SecurityEventStoreError, LoginAttemptStoreError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Security activity is temporarily unavailable"
|
||||
)
|
||||
@@ -1032,6 +1063,7 @@ async def list_security_events(
|
||||
}
|
||||
for event in page.events
|
||||
],
|
||||
"authentication_alerts": authentication_alerts,
|
||||
"next_cursor": page.next_cursor,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
@@ -1326,6 +1358,34 @@ async def create_passkey_authentication_options(request: Request):
|
||||
credentials = await asyncio.to_thread(store.all)
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
source = client_source(
|
||||
peer_host,
|
||||
request.headers.get("x-forwarded-for", ""),
|
||||
os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""),
|
||||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = await asyncio.to_thread(attempts.retry_after, source)
|
||||
if not retry_after:
|
||||
retry_after = await asyncio.to_thread(
|
||||
attempts.admit,
|
||||
"passkey_options",
|
||||
source,
|
||||
limit=int(os.getenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "10")),
|
||||
)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
return JSONResponse(
|
||||
{"detail": "Too many passkey sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)},
|
||||
)
|
||||
rp_id, _origin = _passkey_relying_party(request)
|
||||
options, challenge = passkeys.authentication_options(
|
||||
rp_id=rp_id,
|
||||
@@ -1338,6 +1398,7 @@ async def create_passkey_authentication_options(request: Request):
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source=source,
|
||||
)
|
||||
return JSONResponse(options, headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -1424,10 +1485,39 @@ async def verify_passkey_authentication(
|
||||
):
|
||||
if payload.action != "sign_in" or payload.target != "dashboard":
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey sign-in target")
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
source = client_source(
|
||||
peer_host,
|
||||
request.headers.get("x-forwarded-for", ""),
|
||||
os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""),
|
||||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = await asyncio.to_thread(attempts.retry_after, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
return JSONResponse(
|
||||
{"detail": "Too many passkey sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
challenge = passkeys.decode(payload.challenge)
|
||||
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
|
||||
store = _passkey_store()
|
||||
valid = await asyncio.to_thread(
|
||||
@@ -1440,6 +1530,14 @@ async def verify_passkey_authentication(
|
||||
)
|
||||
stored = await asyncio.to_thread(store.get, credential_id)
|
||||
if not valid or stored is None:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Passkey sign-in failed")
|
||||
rp_id, origin = _passkey_relying_party(request)
|
||||
try:
|
||||
@@ -1465,7 +1563,24 @@ async def verify_passkey_authentication(
|
||||
except dashboard_auth.SessionStoreError:
|
||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||
except Exception as exc:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc
|
||||
try:
|
||||
await asyncio.to_thread(attempts.clear, source)
|
||||
except LoginAttemptStoreError:
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
@@ -2963,6 +3078,34 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/acknowledge")
|
||||
async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.acknowledge_notification(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Acknowledging the update timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "This update has no comment that can be acknowledged."},
|
||||
status_code=422,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The update could not be acknowledged. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
await _remove_notifications_from_live_snapshot([thread_id])
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
|
||||
async def reply_to_notification(
|
||||
reply: NotificationReply,
|
||||
@@ -3213,6 +3356,65 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
||||
)
|
||||
|
||||
|
||||
async def _mutate_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
remove: bool,
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.mutate_assigned_issue_dependency(
|
||||
f"{owner}/{repo}", number, update.repository, update.number, remove
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except gitea_proxy.IssueDependencyInvalidError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=422)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The blocker change could not be confirmed. Reload the issue before retrying."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def add_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, False)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def remove_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, True)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def set_assigned_issue_blocker_state(
|
||||
update: IssueBlockerDesiredState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(
|
||||
update, owner, repo, number, not update.present
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
|
||||
+46
-3
@@ -20,9 +20,18 @@ class StoredPasskey:
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_challenges: int = 10_000,
|
||||
max_challenges_per_source: int = 10,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_challenges = max(1, max_challenges)
|
||||
self.max_challenges_per_source = max(1, max_challenges_per_source)
|
||||
|
||||
@staticmethod
|
||||
def _digest(value: bytes | str) -> str:
|
||||
@@ -50,6 +59,7 @@ class PasskeyStore:
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT,
|
||||
source_hash TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
@@ -57,6 +67,14 @@ class PasskeyStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(passkey_challenges)")
|
||||
}
|
||||
if "source_hash" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
@@ -69,25 +87,50 @@ class PasskeyStore:
|
||||
purpose: str,
|
||||
action: str,
|
||||
target: str,
|
||||
source: str | None = None,
|
||||
ttl_seconds: int = 120,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_challenges("
|
||||
"challenge_hash, session_hash, purpose, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(challenge),
|
||||
self._digest(session_id) if session_id else None,
|
||||
source_hash,
|
||||
purpose,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM passkey_challenges
|
||||
WHERE source_hash = ? AND challenge_hash NOT IN (
|
||||
SELECT challenge_hash FROM passkey_challenges
|
||||
WHERE source_hash = ?
|
||||
ORDER BY expires_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(source_hash, source_hash, self.max_challenges_per_source),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM passkey_challenges
|
||||
WHERE challenge_hash NOT IN (
|
||||
SELECT challenge_hash FROM passkey_challenges
|
||||
ORDER BY expires_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_challenges,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
|
||||
@@ -69,3 +69,20 @@ async def runtime_bundle(digest: str, request: Request) -> Response:
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feature-{name}-{digest}.js")
|
||||
async def feature_bundle(name: str, digest: str, request: Request) -> Response:
|
||||
bundle = FRONTEND_BUILD.feature_bundles.get(name)
|
||||
if bundle is None or digest != bundle.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Feature revision not found")
|
||||
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||
return Response(
|
||||
bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes,
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Vary": "Accept-Encoding",
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
@@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
|
||||
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["blockerRepository"] == "stackchain/api"
|
||||
assert output["queued"]["blockerNumber"] == 9
|
||||
assert output["queued"]["present"] is False
|
||||
assert output["restored"]["present"] is False
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-op",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-1",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}
|
||||
assert output["state"]["completed"] == ["blocker-1"]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
|
||||
|
||||
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
|
||||
const state={{completed:[],failed:[]}};
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -167,6 +167,128 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_options_are_source_limited_before_challenge_generation(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "2")
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
first_source = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
other_source = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
|
||||
async with httpx.AsyncClient(transport=first_source, base_url="https://test") as client:
|
||||
first = await client.post("/api/v1/passkeys/authentication/options")
|
||||
second = await client.post("/api/v1/passkeys/authentication/options")
|
||||
blocked = await client.post("/api/v1/passkeys/authentication/options")
|
||||
async with httpx.AsyncClient(transport=other_source, base_url="https://test") as client:
|
||||
available = await client.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert first.status_code == second.status_code == available.status_code == 200
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json() == {"detail": "Too many passkey sign-in attempts"}
|
||||
assert blocked.headers["retry-after"] == "60"
|
||||
with sqlite3.connect(main._passkey_store().path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM passkey_challenges WHERE purpose = 'authentication'"
|
||||
).fetchone() == (3,)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_passkey_verification_consumes_the_shared_sign_in_budget(
|
||||
access_control,
|
||||
):
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
failures = []
|
||||
for _attempt in range(3):
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
failures.append(
|
||||
await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "d3JvbmctY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
)
|
||||
blocked = await client.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401]
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.headers["retry-after"] == "60"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_passkey_sign_in_clears_prior_source_failures(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedAuthentication:
|
||||
new_sign_count = 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: VerifiedAuthentication(),
|
||||
)
|
||||
_signed, _managed_session = await asyncio.to_thread(
|
||||
main.dashboard_auth.issue_session,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
source = "203.0.113.10"
|
||||
attempts = main._login_attempt_store()
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
transport = httpx.ASGITransport(app=main.app, client=(source, 1234))
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
signed_in = await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
assert signed_in.status_code == 200, signed_in.text
|
||||
with sqlite3.connect(attempts.path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM login_attempts WHERE source_hash = ?",
|
||||
(attempts._digest(source),),
|
||||
).fetchone() == (0,)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
access_control, monkeypatch
|
||||
@@ -703,6 +825,44 @@ async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_co
|
||||
assert blocked.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_activity_surfaces_failed_and_blocked_token_sign_ins(access_control):
|
||||
attacker_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
operator_transport = httpx.ASGITransport(app=main.app, client=("198.51.100.4", 1234))
|
||||
async with httpx.AsyncClient(
|
||||
transport=attacker_transport, base_url="https://test"
|
||||
) as attacker:
|
||||
failures = [
|
||||
await attacker.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "wrong", "device_label": "Unknown"},
|
||||
)
|
||||
for _ in range(4)
|
||||
]
|
||||
async with httpx.AsyncClient(
|
||||
transport=operator_transport, base_url="https://test"
|
||||
) as operator:
|
||||
signed_in = await operator.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Phone",
|
||||
},
|
||||
)
|
||||
activity = await operator.get("/api/v1/security-events")
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401, 429]
|
||||
assert signed_in.status_code == 200
|
||||
assert activity.status_code == 200
|
||||
assert activity.headers["cache-control"] == "no-store"
|
||||
alerts = activity.json()["authentication_alerts"]
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0]["method"] == "token"
|
||||
assert alerts[0]["failed_count"] == 3
|
||||
assert alerts[0]["blocked_count"] == 1
|
||||
assert alerts[0]["first_at"] <= alerts[0]["last_at"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_clears_prior_failures(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
@@ -1433,6 +1593,9 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
time.sleep(0.15)
|
||||
return 17
|
||||
|
||||
def record_blocked(self, *, method):
|
||||
assert method == "token"
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
|
||||
@@ -736,6 +736,34 @@ process.stdout.write(JSON.stringify(state));
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_formats_failed_sign_in_alerts_for_mobile_scanability():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.formatted = boundary.formatAuthenticationAlert({
|
||||
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
|
||||
});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["formatted"] == {
|
||||
"title": "12 failed token sign-ins · 3 blocked",
|
||||
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
|
||||
}
|
||||
|
||||
|
||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
alerts = source.index("page.authentication_alerts.forEach")
|
||||
events = source.index("page.events.forEach")
|
||||
assert alerts < events
|
||||
assert "boundary.formatAuthenticationAlert(alert)" in source
|
||||
assert "row.className = 'security-event authentication-alert'" in source
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
assert ".authentication-alert { border-color:#d69e2e;" in css
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js"
|
||||
|
||||
|
||||
def run_loader(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const state={{appends:0, removed:0}};
|
||||
const document={{
|
||||
head:{{appendChild(node){{ state.appends++; state.node=node; }} }},
|
||||
createElement(){{ return {{remove(){{state.removed++;}}}}; }},
|
||||
}};
|
||||
const context={{document, setTimeout, clearTimeout, console}};
|
||||
context.globalThis=context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context);
|
||||
const createFeatureLoader=context.createFeatureLoader;
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_concurrent_feature_requests_share_one_script():
|
||||
result = run_loader("""
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const first=loader.load('issue-capture'); const second=loader.load('issue-capture');
|
||||
state.node.onload(); await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')}));
|
||||
""")
|
||||
assert result == {"appends": 1, "same": True, "ready": True}
|
||||
|
||||
|
||||
def test_failed_feature_request_can_retry_without_reload():
|
||||
result = run_loader("""
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror();
|
||||
const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry;
|
||||
console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')}));
|
||||
""")
|
||||
assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True}
|
||||
|
||||
|
||||
def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;});
|
||||
const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror();
|
||||
const first=await failed;
|
||||
const failedState={disabled:trigger.disabled,status:status.textContent,opened};
|
||||
const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload();
|
||||
const second=await retry;
|
||||
console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"loading": {"disabled": True, "status": "Loading issue capture…"},
|
||||
"failedState": {
|
||||
"disabled": False,
|
||||
"status": "Issue capture could not load. Tap New issue to retry.",
|
||||
"opened": 0,
|
||||
},
|
||||
"first": False,
|
||||
"second": True,
|
||||
"opened": 1,
|
||||
"finalStatus": "",
|
||||
}
|
||||
|
||||
|
||||
def test_pull_workspace_failure_is_accessible_and_retryable_from_same_card():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'pull-workflow':'feature-pull.js'}, timeoutMs:100});
|
||||
const failed=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
|
||||
state.node.onerror(); const first=await failed;
|
||||
const failedStatus=status.textContent;
|
||||
const retry=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
|
||||
state.node.onload(); const second=await retry;
|
||||
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||
""")
|
||||
assert result == {
|
||||
"first": False,
|
||||
"second": True,
|
||||
"failedStatus": "Pull workspace could not load. Tap the work card to retry.",
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from starlette.requests import Request
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
@@ -42,6 +42,58 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
assert b"function createIssueCapture" in capture.runtime_bytes
|
||||
assert b"function createPullSheet" not in first.runtime_bytes
|
||||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
capture_source = changed_frontend / "create-issue-sheet.js"
|
||||
capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name
|
||||
|
||||
pull_source = changed_frontend / "pull-sheet.js"
|
||||
pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n")
|
||||
pull_changed = build_frontend(changed_frontend)
|
||||
assert pull_changed.runtime_name == first.runtime_name
|
||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||
build = build_frontend(FRONTEND)
|
||||
capture = build.feature_bundles["issue-capture"]
|
||||
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"identity")]}
|
||||
)
|
||||
response = await feature_bundle("issue-capture", capture.runtime_digest, request)
|
||||
|
||||
assert response.body == capture.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
with pytest.raises(Exception) as rejected:
|
||||
await feature_bundle("issue-capture", "missing", request)
|
||||
assert rejected.value.status_code == 404
|
||||
|
||||
|
||||
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
|
||||
@@ -182,6 +182,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
|
||||
@@ -1939,3 +1939,193 @@ async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
|
||||
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{
|
||||
"repository": blocker_repository, "number": blocker_number,
|
||||
"title": "Restore API", "state": "open",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
added = await client.post(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
removed = await client.request(
|
||||
"DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert [added.status_code, removed.status_code] == [200, 200]
|
||||
assert added.headers["cache-control"] == "no-store"
|
||||
assert added.json()["dependencies"][0]["number"] == 9
|
||||
assert removed.json()["dependencies"] == []
|
||||
assert calls == [
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, False),
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_patch_converges_to_desired_state(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{"repository": blocker_repository, "number": blocker_number}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
headers={"Idempotency-Key": "offline-blocker-1"},
|
||||
json={"repository": "stackchain/api", "number": 9, "present": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["dependencies"] == []
|
||||
assert calls == [("stackchain/dashboard", 17, "stackchain/api", 9, True)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
||||
async def mutate(*_args, **_kwargs):
|
||||
raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {"error": "blocker must be an open issue"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
mutated = any(r.method == "POST" for r in requests)
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}] if mutated else [])
|
||||
if request.method == "POST" and path.endswith("/dependencies"):
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
mutation = next(request for request in requests if request.method == "POST")
|
||||
assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies"
|
||||
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
|
||||
assert result["dependencies"] == [{
|
||||
"repository": "stackchain/api", "number": 9, "title": "Restore API",
|
||||
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
|
||||
mutations = []
|
||||
dependency = {
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=[dependency])
|
||||
if path.endswith("/dependencies"):
|
||||
mutations.append(request.method)
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert mutations == []
|
||||
assert result["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
||||
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
||||
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
|
||||
])
|
||||
async def test_gitea_dependency_rejects_invalid_candidates(
|
||||
candidate, blocker_repository, blocker_number, existing
|
||||
):
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if request.url.path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=existing)
|
||||
return httpx.Response(200, json=candidate)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueDependencyInvalidError):
|
||||
await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, blocker_repository, blocker_number
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
@@ -72,3 +72,122 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
|
||||
|
||||
def test_failed_sign_ins_are_aggregated_across_workers_and_survive_throttle_clear(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=300,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=300,
|
||||
)
|
||||
|
||||
first.record_failure("203.0.113.7", method="token")
|
||||
now[0] = 1_010.0
|
||||
second.record_failure("198.51.100.4", method="token")
|
||||
first.clear("203.0.113.7")
|
||||
|
||||
assert second.list_alerts() == [
|
||||
{
|
||||
"method": "token",
|
||||
"failed_count": 2,
|
||||
"blocked_count": 0,
|
||||
"first_at": 1_000,
|
||||
"last_at": 1_010,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_rate_blocked_sign_ins_increment_a_distinct_privacy_safe_count(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
store.record_blocked(method="token")
|
||||
|
||||
assert store.list_alerts() == [
|
||||
{
|
||||
"method": "token",
|
||||
"failed_count": 0,
|
||||
"blocked_count": 1,
|
||||
"first_at": 1_000,
|
||||
"last_at": 1_000,
|
||||
}
|
||||
]
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
columns = [row[1] for row in connection.execute("PRAGMA table_info(login_alerts)")]
|
||||
assert "source_hash" not in columns
|
||||
|
||||
|
||||
def test_expired_sign_in_alert_buckets_are_not_returned(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_retention_seconds=300,
|
||||
)
|
||||
store.record_failure("203.0.113.7")
|
||||
|
||||
now[0] = 1_301.0
|
||||
|
||||
assert store.list_alerts() == []
|
||||
|
||||
|
||||
def test_sign_in_alert_bucket_count_is_bounded_during_rotating_source_floods(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=60,
|
||||
max_alert_buckets=2,
|
||||
)
|
||||
for index, timestamp in enumerate((1_000.0, 1_060.0, 1_120.0)):
|
||||
now[0] = timestamp
|
||||
store.record_failure(f"203.0.113.{index}")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
count = connection.execute("SELECT COUNT(*) FROM login_alerts").fetchone()[0]
|
||||
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
||||
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
||||
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 60
|
||||
assert first.admit("passkey_options", "203.0.113.8", limit=2) == 0
|
||||
|
||||
now[0] = 1_060.0
|
||||
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
||||
+224
-9
@@ -811,6 +811,77 @@ controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
||||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/dashboard', number:17}};
|
||||
const blocker = {{repository:'stackchain/api', number:9}};
|
||||
const first = controller.updateBlocker(item, blocker, false);
|
||||
const duplicate = controller.updateBlocker(item, blocker, false);
|
||||
finish({{repository:item.repository, number:item.number, dependencies_available:true,
|
||||
dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate, result:results[0]
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "POST",
|
||||
"body": {"repository": "stackchain/api", "number": 9},
|
||||
}]
|
||||
assert output["result"]["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:async () => ({{dependencies_available:true,
|
||||
dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
|
||||
}});
|
||||
controller.updateBlocker(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9}}, true
|
||||
).then(() => process.stdout.write('unexpected'))
|
||||
.catch(error => process.stdout.write(error.message));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert result.stdout == "Blocker change was not confirmed."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="manage-issue-blockers"' in html
|
||||
assert 'id="issue-blocker-search" type="search"' in html
|
||||
assert 'id="issue-blocker-results" role="listbox"' in html
|
||||
assert 'id="cancel-issue-blocker"' in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
|
||||
assert "api('api/v1/search?q='" in html
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in html
|
||||
assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
|
||||
assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
|
||||
assert "width:100%; max-width:100%; box-sizing:border-box" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -3241,6 +3312,68 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
]
|
||||
|
||||
|
||||
def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const selection = buildMyWork.createNotificationSelection({{
|
||||
limit: 2,
|
||||
onChange: state => states.push(state),
|
||||
}});
|
||||
selection.start();
|
||||
const first = selection.select(42);
|
||||
const duplicate = selection.select(42);
|
||||
selection.toggle(42);
|
||||
selection.select(43);
|
||||
selection.select(44);
|
||||
const second = selection.select(45);
|
||||
selection.cancel();
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["first"] == "selected"
|
||||
assert output["duplicate"] == "already-selected"
|
||||
assert output["second"] == "limit"
|
||||
assert output["states"] == [
|
||||
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
|
||||
{"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
]
|
||||
assert output["snapshot"] == output["states"][-1]
|
||||
|
||||
|
||||
def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const selection = buildMyWork.createNotificationSelection({{
|
||||
onChange: state => states.push(state),
|
||||
}});
|
||||
selection.start();
|
||||
selection.select(42);
|
||||
selection.select(43);
|
||||
selection.select(44);
|
||||
const retained = selection.retain([44, 44, 99]);
|
||||
process.stdout.write(JSON.stringify({{retained, states}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["retained"] == {
|
||||
"active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
|
||||
}
|
||||
assert output["states"][-1] == output["retained"]
|
||||
|
||||
|
||||
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -3368,6 +3501,68 @@ reader.open(original[0], original).then(() =>
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
|
||||
|
||||
def test_notification_reader_acknowledges_once_and_opens_next_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'update', notification_id:42, has_update:true}},
|
||||
{{kind:'update', notification_id:43, has_update:true}},
|
||||
];
|
||||
let release;
|
||||
const calls = [];
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{}},
|
||||
acknowledge: id => new Promise(resolve => {{ calls.push(id); release = resolve; }}),
|
||||
onOpen: item => events.push(['open', item.notification_id]), onDetail: () => {{}},
|
||||
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
|
||||
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
||||
}});
|
||||
(async () => {{
|
||||
await reader.open(items[0]);
|
||||
events.length = 0;
|
||||
const first = reader.acknowledgeAndNext(items);
|
||||
const duplicate = reader.acknowledgeAndNext(items);
|
||||
await Promise.resolve();
|
||||
release({{reaction:'created', status:'read'}});
|
||||
const results = await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{calls, events, results}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [42]
|
||||
assert output["events"] == [
|
||||
["status", "Adding reaction and marking read…"],
|
||||
["items", [43]],
|
||||
["open", 43],
|
||||
["status", "Loading update…"],
|
||||
["status", "Update ready."],
|
||||
]
|
||||
assert output["results"][0]["next"]["notification_id"] == 43
|
||||
assert output["results"][1] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert (
|
||||
'id="acknowledge-update-next" type="button" hidden '
|
||||
'aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>'
|
||||
) in html
|
||||
assert "qs('#acknowledge-update-next').hidden = true;" in html
|
||||
assert "qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;" in html
|
||||
assert "async function acknowledgeNotification(notificationId)" in html
|
||||
assert "'/acknowledge', { method: 'POST'" in html
|
||||
assert "acknowledge: acknowledgeNotification" in html
|
||||
assert "notificationReader.acknowledgeAndNext(lastMyWork)" in html
|
||||
assert "qs('#acknowledge-update-next').disabled = offline;" in html
|
||||
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -4146,7 +4341,10 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
|
||||
assert "pullConversation.append(comment)" in html
|
||||
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
|
||||
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
|
||||
assert '<script src="static/pull-sheet.js"></script>' in html
|
||||
assert 'stackchain-feature-pull-workflow' in html
|
||||
assert "pullWorkflowFeatures.run('pull-workflow'" in html
|
||||
assert "let pullController = null" in html
|
||||
assert "let reviewController = null" in html
|
||||
assert "pullController.load(item)" in html
|
||||
assert "createPullSheet.renderFile" in html
|
||||
assert "pullController.toggleReviewed" in html
|
||||
@@ -4266,6 +4464,14 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||
|
||||
|
||||
def test_new_issue_capture_waits_for_retryable_feature_before_opening():
|
||||
dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source
|
||||
assert "if (!issueCapture)" in dashboard_source
|
||||
assert "async function openCreateIssueSheet" in dashboard_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
html = await dashboard()
|
||||
@@ -4411,18 +4617,26 @@ async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="bulk-mark-read"' in html
|
||||
assert 'id="bulk-mark-read-bar"' in html
|
||||
assert 'class="my-work-bulk"' in html
|
||||
assert 'id="select-updates"' in html
|
||||
assert 'id="cancel-update-selection"' in html
|
||||
assert 'id="update-selection-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'class="update-selector"' in html
|
||||
assert 'type="checkbox"' in html
|
||||
assert 'Select update ' in html
|
||||
assert '.update-selector { min-height:44px;' in html
|
||||
assert '.my-work-bulk { position:sticky;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
||||
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
||||
assert "const ids = allIds.slice(0, 50)" in html
|
||||
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||
assert "'Mark ' + selection.count + ' selected read'" in html
|
||||
assert "'Confirm marking ' + selection.count + ' selected read'" in html
|
||||
assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
|
||||
assert 'notificationSelection.retain(result.failed)' in html
|
||||
assert "notificationSelection.cancel()" in html
|
||||
assert "createBulkNotificationAcknowledger" in html
|
||||
assert "api/v1/notifications/read" in html
|
||||
assert "body: JSON.stringify({ ids })" in html
|
||||
@@ -4439,7 +4653,8 @@ async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile(
|
||||
assert "api/v1/notifications?page=" in html
|
||||
assert "snapshot.notification_pagination" in html
|
||||
assert "notificationPager.loadMore(lastNotifications)" in html
|
||||
assert "Math.min(lastNotifications.length, 50)" in html
|
||||
assert "createNotificationSelection({" in html
|
||||
assert "limit: 50" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -66,6 +66,7 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
|
||||
"assignees": [],
|
||||
"claimable": True,
|
||||
}
|
||||
assert result["acknowledge_supported"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -41,6 +41,117 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
calls.append(("fetch", path))
|
||||
if path == "notifications/threads/42":
|
||||
return {
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Issue",
|
||||
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": (
|
||||
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
|
||||
),
|
||||
},
|
||||
}
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
raise AssertionError(path)
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload=None):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
calls.append(("get", path))
|
||||
return Response([])
|
||||
|
||||
async def post(self, path, headers, json):
|
||||
calls.append(("post", path, json))
|
||||
return Response({"content": "+1"})
|
||||
|
||||
async def patch(self, path, headers):
|
||||
calls.append(("patch", path))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
result = await gitea_proxy.acknowledge_notification(42)
|
||||
|
||||
reaction_path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
|
||||
assert result == {"id": 42, "reaction": "created", "status": "read"}
|
||||
assert calls == [
|
||||
("fetch", "notifications/threads/42"),
|
||||
("fetch", "user"),
|
||||
("get", reaction_path),
|
||||
("post", reaction_path, {"content": "+1"}),
|
||||
("patch", "/api/v1/notifications/threads/42?to-status=read"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_reuses_existing_operator_reaction_on_retry(monkeypatch):
|
||||
posts = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
return {
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "https://forge.example/api/v1/repos/stackchain/api/pulls/7",
|
||||
"latest_comment_url": (
|
||||
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload=None):
|
||||
self.payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
return Response([{"content": "+1", "user": {"login": "timmy"}}])
|
||||
|
||||
async def post(self, path, headers, json):
|
||||
posts.append((path, json))
|
||||
return Response()
|
||||
|
||||
async def patch(self, path, headers):
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
|
||||
result = await gitea_proxy.acknowledge_notification(42)
|
||||
|
||||
assert result == {"id": 42, "reaction": "existing", "status": "read"}
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
marked = []
|
||||
@@ -61,6 +172,33 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def acknowledge(thread_id):
|
||||
calls.append(thread_id)
|
||||
return {"id": thread_id, "reaction": "created", "status": "read"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "acknowledge_notification", acknowledge, raising=False)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{"notifications": [{"id": 42}, {"id": 43}]},
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/api/v1/notifications/42/acknowledge")
|
||||
invalid = await client.post("/api/v1/notifications/0/acknowledge")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "reaction": "created", "status": "read"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [42]
|
||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
|
||||
async def mark(_thread_id):
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_offline_blocker_reports_queued_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
const events=[];
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
createOperationId:()=> 'blocker-op',
|
||||
enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}},
|
||||
onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]),
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await queue(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9,title:'Restore API'}},
|
||||
false
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{events,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == [
|
||||
["admit", {
|
||||
"kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17,
|
||||
"blockerRepository": "stackchain/api", "blockerNumber": 9,
|
||||
"present": False, "operationId": "blocker-op",
|
||||
}],
|
||||
["queued", 17, 9, False, "background"],
|
||||
]
|
||||
assert output["result"]["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
def test_offline_blocker_does_not_report_queued_when_durable_admission_fails():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
let queued=false;
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}},
|
||||
onQueued:()=>queued=true,
|
||||
}});
|
||||
queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true)
|
||||
.then(()=>process.stdout.write(JSON.stringify({{queued}})))
|
||||
.catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"queued": False, "error": "IndexedDB unavailable"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet():
|
||||
html = await dashboard()
|
||||
dashboard_js = (MODULE.parent / "dashboard.js").read_text()
|
||||
service_worker = (MODULE.parent / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/offline-issue-blocker.js"></script>' in html
|
||||
assert "BASE + 'static/offline-issue-blocker.js'" in service_worker
|
||||
assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js
|
||||
assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js
|
||||
assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js
|
||||
assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js
|
||||
assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js
|
||||
@@ -0,0 +1,106 @@
|
||||
import sqlite3
|
||||
|
||||
from src.passkey_store import PasskeyStore
|
||||
|
||||
|
||||
def test_active_challenges_are_bounded_per_source_and_globally_across_instances(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
first = PasskeyStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_challenges=3,
|
||||
max_challenges_per_source=2,
|
||||
)
|
||||
second = PasskeyStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_challenges=3,
|
||||
max_challenges_per_source=2,
|
||||
)
|
||||
|
||||
for challenge in (b"first", b"second", b"third"):
|
||||
first.issue_challenge(
|
||||
challenge,
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.7",
|
||||
)
|
||||
second.issue_challenge(
|
||||
b"other-one",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.8",
|
||||
)
|
||||
second.issue_challenge(
|
||||
b"other-two",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.8",
|
||||
)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (3,)
|
||||
counts = connection.execute(
|
||||
"SELECT source_hash, COUNT(*) FROM passkey_challenges GROUP BY source_hash"
|
||||
).fetchall()
|
||||
assert sorted(count for _source, count in counts) == [1, 2]
|
||||
|
||||
now[0] = 1_121.0
|
||||
first.issue_challenge(
|
||||
b"after-expiry",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.9",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(tmp_path):
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE passkey_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_challenges VALUES ('existing', NULL, "
|
||||
"'authentication', 'sign_in', 'dashboard', 1120)"
|
||||
)
|
||||
store = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
|
||||
store.issue_challenge(
|
||||
b"new",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.7",
|
||||
)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(passkey_challenges)")
|
||||
}
|
||||
rows = connection.execute(
|
||||
"SELECT challenge_hash FROM passkey_challenges ORDER BY challenge_hash"
|
||||
).fetchall()
|
||||
assert "source_hash" in columns
|
||||
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
|
||||
@@ -53,6 +53,7 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
"authentication_alerts": [],
|
||||
"next_cursor": None,
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
|
||||
@@ -419,6 +419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
@@ -429,6 +430,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/offline-issue-blocker.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
|
||||
Reference in new issue
Block a user