Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18c59c7b3 | ||
|
|
e9cab92aff | ||
|
|
877dc475dd | ||
|
|
28e70f6415 | ||
|
|
2a6f2ab12d | ||
|
|
c8809a0728 | ||
|
|
2717f41957 | ||
|
|
b26b57d616 | ||
|
|
1052ce2c84 | ||
|
|
4f0e6544b3 | ||
|
|
91c59a2953 | ||
|
|
6d1fa3c068 | ||
|
|
21a386b94f | ||
|
|
7e5b966f77 | ||
|
|
78d044f1af | ||
|
|
814c5726ec | ||
|
|
95a14c6de5 | ||
|
|
37aa2dd090 | ||
|
|
6565165295 | ||
|
|
4749d15d85 | ||
|
|
16604d697b | ||
|
|
59b4d5e6bf | ||
|
|
83af187c73 | ||
|
|
d4896e10be | ||
|
|
81e1af7f16 | ||
|
|
b1b3f758e4 |
No files matched your search
@@ -19,17 +19,18 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB.
|
||||
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot; the mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted; validation or upload
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. Durable admission writes the complete screenshot capture to IndexedDB
|
||||
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
||||
quota pressure and synchronous multi-megabyte writes. Existing queued screenshot payloads
|
||||
migrate to the IndexedDB-backed form on the next durable admission or worker reconciliation.
|
||||
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
||||
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
||||
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
@@ -38,7 +39,12 @@ issue-comment API. In issue, pull-request, and unread-update conversations, typi
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
|
||||
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
|
||||
repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
@@ -67,6 +73,10 @@ restart, **Resume Today** reopens the saved item (or the next surviving item if
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
for the current unread-update conversation. It deliberately leaves the notification unread;
|
||||
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
@@ -154,6 +164,8 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
||||
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
||||
# Recommended behind a proxy; WebAuthn assertions must match these public values.
|
||||
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
||||
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
||||
@@ -199,14 +211,32 @@ session hashes, CSRF proofs, or source addresses. The registry also stores each
|
||||
session's last explicit activity. Existing two-column registries are migrated in
|
||||
place, their live sessions remain valid, and their idle clock starts at migration.
|
||||
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, sign-outs, remote device revocations, issue
|
||||
closures, and pull-request merges. The separate SQLite journal retains at most
|
||||
10,000 events for 90 days and stores only bounded device labels and action targets.
|
||||
It never stores access tokens, cookies, session/CSRF values, credential IDs, raw
|
||||
network addresses, request bodies, or comment content. Keep its database on the
|
||||
same class of persistent, writable storage as the session registry. Before a
|
||||
session revocation or issue closure, the journal durably reserves a pending event;
|
||||
if that reservation fails, the destructive action does not begin. A successful
|
||||
action remains truthfully reported even if its event cannot immediately be finalized,
|
||||
and the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||
|
||||
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
|
||||
WebAuthn credential with required user verification. That device can then sign in
|
||||
and authorize high-impact actions with its biometric/PIN gesture. The access token
|
||||
remains the recovery fallback for browsers without WebAuthn or devices without an
|
||||
enrolled credential. Registration and authentication challenges are exact-purpose,
|
||||
single-use, and short-lived. Remotely revoking an enrolled device deletes its passkey
|
||||
as well as its active session; signing out normally keeps the passkey available for
|
||||
the next sign-in.
|
||||
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
|
||||
including those whose original session has expired or signed out, using only its
|
||||
bounded label, enrollment time, and active/current status. Removing one requires a
|
||||
single-use fresh authorization bound to that exact management ID. A remote active
|
||||
session linked to the credential is revoked atomically; unrelated credentials and
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
|
||||
@@ -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', 'pull-review', 'issue-close']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -74,6 +74,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(message.kind === 'issue-comment' && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
@@ -106,7 +107,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
data: String(message.attachment.data || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
} : candidate);
|
||||
try {
|
||||
@@ -182,7 +184,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
if (item.kind === 'issue-close') {
|
||||
if (item.kind === 'update-reply-read') {
|
||||
if (!item.replyConfirmed) {
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||
} : candidate), false);
|
||||
}
|
||||
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
} else if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
@@ -296,6 +315,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
if (background?.status === 'authorization') return [{
|
||||
...item,
|
||||
status: 'authorization',
|
||||
error: String(background.error || 'Fresh authorization required').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
|
||||
@@ -77,13 +77,16 @@ function createIssueSyncStore({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.operationId === replacement.operationId && current.attachment?.data &&
|
||||
replacement.attachment?.stored && !replacement.attachment.data) {
|
||||
if (current.operationId === replacement.operationId &&
|
||||
(current.attachment?.data || current.attachment?.blob) &&
|
||||
replacement.attachment?.stored &&
|
||||
!replacement.attachment.data && !replacement.attachment.blob) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
current.status === 'sent') {
|
||||
incoming.set(current.id, current);
|
||||
}
|
||||
@@ -151,9 +154,11 @@ function createIssueSyncStore({
|
||||
return transact(async records => {
|
||||
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
item.status === current.status) ||
|
||||
current.status === 'sent')) return current;
|
||||
const preservedAttachment = current?.attachment?.data && item?.attachment?.stored && !item.attachment.data
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
@@ -240,6 +245,9 @@ function createIssueSyncStore({
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
authorization: (id, claimToken, error) => updateClaim(
|
||||
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
|
||||
),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
@@ -268,6 +276,9 @@ function createBackgroundIssueSync({
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const requireAuthorization = (item, error) => store.supportsClaimTokens
|
||||
? store.authorization(item.id, item.claimToken, error)
|
||||
: store.authorization(item.id, error);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
@@ -308,7 +319,7 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
@@ -341,6 +352,12 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
if (item.kind === 'update-reply-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
@@ -417,6 +434,35 @@ function createBackgroundIssueSync({
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
authoredRequest('', current).options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
current = { ...current, replyConfirmed: true };
|
||||
}
|
||||
const request = deliveryRequest(current);
|
||||
return requestStage(current, request.url, request.options);
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
async function deliverIssueCapture(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
@@ -433,14 +479,10 @@ function createBackgroundIssueSync({
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
@@ -476,14 +518,10 @@ function createBackgroundIssueSync({
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
@@ -512,7 +550,8 @@ function createBackgroundIssueSync({
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.attachment && item.kind === 'issue-comment' ?
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.attachment && item.kind === 'issue-comment' ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
@@ -529,6 +568,15 @@ function createBackgroundIssueSync({
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status === 428 && item.kind === 'pull-review') {
|
||||
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
|
||||
await requireAuthorization(item, message);
|
||||
return {
|
||||
authorization: true,
|
||||
error,
|
||||
receipt: receiptFor(item, 'authorization'),
|
||||
};
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await failClaim(
|
||||
item,
|
||||
@@ -559,11 +607,13 @@ function createBackgroundIssueSync({
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
let authorization = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.authorization) authorization += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.planBatch) {
|
||||
@@ -624,7 +674,7 @@ function createBackgroundIssueSync({
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
return { confirmed, blocked, attention, authorization, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
|
||||
@@ -24,6 +24,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
@@ -159,6 +160,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return request;
|
||||
}
|
||||
|
||||
async function searchRepositories(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++repositorySearchRequest;
|
||||
if (query.length < 2) return {status: 'idle', items: []};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
|
||||
);
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {
|
||||
status: 'ready',
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {status: 'failed', items: [], error};
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -230,7 +250,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
|
||||
+24
-2
@@ -16,11 +16,22 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button { min-height:44px; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
|
||||
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
|
||||
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
|
||||
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.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; }
|
||||
.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); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
@@ -233,6 +244,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-reply-actions button { min-height:44px; width:100%; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.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 a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
@@ -349,6 +362,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||
#create-issue-repository-results[hidden] { display:none; }
|
||||
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
.create-issue-repository-result:last-child { border-bottom:0; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
@@ -429,7 +448,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-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; }
|
||||
.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; }
|
||||
@@ -440,9 +459,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
|
||||
+115
-35
@@ -9,6 +9,7 @@
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
||||
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
||||
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
||||
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
|
||||
],
|
||||
});
|
||||
mobileComposerViewport.start();
|
||||
@@ -295,14 +296,9 @@
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': payload.operation_id,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: payload.filename,
|
||||
content_type: payload.content_type,
|
||||
data: payload.data,
|
||||
}),
|
||||
body: issueAttachment.multipart(payload),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -566,6 +562,7 @@
|
||||
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
||||
});
|
||||
const updateOwnership = createUpdateOwnership({
|
||||
available: () => createAndStart.available(),
|
||||
claim: item => fetchReviewJson(
|
||||
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number) + '/claim',
|
||||
@@ -581,6 +578,12 @@
|
||||
}
|
||||
return result;
|
||||
},
|
||||
start: item => {
|
||||
const outcome = createAndStart.complete(item);
|
||||
if (outcome === 'started') openRoutedWork(item, qs('#update-ownership-start'));
|
||||
return outcome;
|
||||
},
|
||||
recover: item => openRoutedWork(item, qs('#update-ownership-start')),
|
||||
onClaimed: item => {
|
||||
if (!lastContextSnapshot) return;
|
||||
const issues = (lastContextSnapshot.issues || []).filter(candidate =>
|
||||
@@ -596,6 +599,12 @@
|
||||
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
|
||||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||||
},
|
||||
onStartState: state => {
|
||||
const button = qs('#update-ownership-start');
|
||||
button.hidden = state.action === 'hidden';
|
||||
button.disabled = state.busy;
|
||||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||||
},
|
||||
});
|
||||
const notificationReader = createNotificationReader({
|
||||
load: fetchNotificationDetail,
|
||||
@@ -619,9 +628,9 @@
|
||||
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
||||
qs('#update-reply-status').textContent = '';
|
||||
qs('#send-update-reply').disabled = false;
|
||||
qs('#send-update-reply-next').disabled = false;
|
||||
setUpdateReplyNextVisibility();
|
||||
qs('#send-update-reply-read-next').disabled = false;
|
||||
qs('#update-ownership-action').hidden = true;
|
||||
qs('#update-ownership-start').hidden = true;
|
||||
qs('#retry-update-load').hidden = true;
|
||||
setOfflineUpdateControls(false);
|
||||
qs('#keep-update-unread').focus();
|
||||
@@ -1023,9 +1032,7 @@
|
||||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
||||
}
|
||||
function setUpdateReplyNextVisibility() {
|
||||
qs('#send-update-reply-next').hidden = !selectedUpdate || !workSession.checkpointed(selectedUpdate);
|
||||
}
|
||||
|
||||
const issueCommentNext = createCommentNext({
|
||||
post: async (item, body) => {
|
||||
const comment = await issueController.comment(item, body);
|
||||
@@ -1060,25 +1067,16 @@
|
||||
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||
}),
|
||||
});
|
||||
const updateReplyNext = createCommentNext({
|
||||
queueKind: 'update-reply',
|
||||
const updateReplyReadNext = createUpdateReplyReadNext({
|
||||
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
||||
markRead: markNotificationRead,
|
||||
queue: message => authoredOutbox.enqueueDurably(message),
|
||||
canQueue: canQueueMessage,
|
||||
accept: (item, result) => {
|
||||
accept: item => {
|
||||
notificationReplier.saveDraft(item, '');
|
||||
if (selectedUpdate === item) {
|
||||
qs('#update-reply').value = '';
|
||||
if (result.comment) notificationReader.appendReply(result.comment);
|
||||
qs('#update-reply-status').textContent = result.delivery === 'queued' ?
|
||||
'Reply queued for background delivery.' : result.delivery === 'saved' ?
|
||||
'Reply saved for next-launch delivery.' : 'Reply posted.';
|
||||
}
|
||||
if (selectedUpdate === item) qs('#update-reply').value = '';
|
||||
},
|
||||
complete: item => completeTodayItem(item, {
|
||||
successMessage: 'Reply saved. Next Today item opened.',
|
||||
failureMessage: 'Reply saved, but Today still needs completion.',
|
||||
}),
|
||||
next: item => notificationReader.acceptReadAndNext(lastMyWork, item),
|
||||
});
|
||||
const closeOfflineIssue = createOfflineIssueClose({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
@@ -1477,6 +1475,7 @@
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
||||
qs('#update-ownership-action').disabled = offline;
|
||||
qs('#update-ownership-start').disabled = offline;
|
||||
qs('#load-older-update-comments').disabled = offline;
|
||||
qs('#update-sheet .detail-defer').inert = offline;
|
||||
}
|
||||
@@ -1658,6 +1657,10 @@
|
||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
reviewOutbox && item.status === 'authorization' ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
|
||||
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & send review</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
reviewOutbox && item.status === 'attention' ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
||||
@@ -1779,11 +1782,17 @@
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.outbox_id || !activeFlushLogin) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Fresh authorization required for this exact issue.';
|
||||
const reviewAuthorization = item.outbox_kind === 'pull-review';
|
||||
qs('#my-work-action-status').textContent = reviewAuthorization ?
|
||||
'Fresh authorization required for this exact review decision and head.' :
|
||||
'Fresh authorization required for this exact issue.';
|
||||
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
|
||||
applyAuthoredOutboxResult(result);
|
||||
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||||
'Issue closed and queued intent cleared.' : 'Issue closure was not confirmed. The queued intent is still safe.';
|
||||
(reviewAuthorization ? 'Review submitted and queued intent cleared.' :
|
||||
'Issue closed and queued intent cleared.') :
|
||||
(reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
|
||||
'Issue closure was not confirmed. The queued intent is still safe.');
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
@@ -2558,6 +2567,7 @@
|
||||
let issueCaptureRepositories = [];
|
||||
let nextIssueRepositoryPage = 2;
|
||||
let moreIssueRepositoriesAvailable = false;
|
||||
let issueRepositorySearchTimer = null;
|
||||
|
||||
function appendIssueRepositories(items) {
|
||||
const select = qs('#create-issue-repository');
|
||||
@@ -2576,6 +2586,43 @@
|
||||
if (selected) select.value = selected;
|
||||
}
|
||||
|
||||
function updateIssueCreateActions() {
|
||||
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
||||
qs('#submit-new-issue').disabled = !hasRepository;
|
||||
qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available();
|
||||
}
|
||||
|
||||
function renderIssueRepositoryResults(items) {
|
||||
const results = qs('#create-issue-repository-results');
|
||||
results.replaceChildren();
|
||||
(Array.isArray(items) ? items : []).forEach(item => {
|
||||
const repository = String(item?.full_name || '').trim();
|
||||
if (!repository) return;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'create-issue-repository-result';
|
||||
button.setAttribute('role', 'option');
|
||||
button.textContent = repository;
|
||||
button.addEventListener('click', () => selectIssueCaptureRepository(repository));
|
||||
results.appendChild(button);
|
||||
});
|
||||
results.hidden = !results.childElementCount;
|
||||
}
|
||||
|
||||
function selectIssueCaptureRepository(repository) {
|
||||
appendIssueRepositories([{full_name: repository}]);
|
||||
qs('#create-issue-repository').value = repository;
|
||||
qs('#create-issue-repository-search').value = repository;
|
||||
qs('#create-issue-repository-results').hidden = true;
|
||||
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
||||
loadIssueLabels(repository);
|
||||
loadIssueMilestones(repository);
|
||||
saveIssueCaptureDraft();
|
||||
scheduleIssueDuplicateCheck();
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
|
||||
let duplicateCheckTimer = null;
|
||||
function renderIssueDuplicates(state) {
|
||||
if (state.status === 'stale') return;
|
||||
@@ -2703,7 +2750,7 @@
|
||||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||||
issueCaptureRepositories.unshift(captureDraft.repository);
|
||||
}
|
||||
qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository =>
|
||||
qs('#create-issue-repository').innerHTML = '<option value="">Choose repository</option>' + issueCaptureRepositories.map(repository =>
|
||||
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
||||
).join('');
|
||||
if (nextIssueRepositoryPage === 2) {
|
||||
@@ -2711,6 +2758,9 @@
|
||||
}
|
||||
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
|
||||
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
||||
else qs('#create-issue-repository').value = '';
|
||||
qs('#create-issue-repository-search').value = captureDraft.repository || '';
|
||||
qs('#create-issue-repository-results').hidden = true;
|
||||
qs('#create-issue-title').value = captureDraft.title;
|
||||
qs('#create-issue-body').value = captureDraft.body;
|
||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||
@@ -2718,11 +2768,10 @@
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#submit-new-issue').disabled = !issueCaptureRepositories.length;
|
||||
qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available();
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
qs('#create-issue-title').focus();
|
||||
(captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus();
|
||||
}
|
||||
|
||||
let suppressCreateDraftOnHistoryClose = false;
|
||||
@@ -2733,6 +2782,7 @@
|
||||
return;
|
||||
}
|
||||
qs('#create-issue-sheet').classList.remove('open');
|
||||
mobileComposerViewport.close(qs('.create-issue-panel'));
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
qs('#create-issue-duplicates').hidden = true;
|
||||
creatingIssue = false;
|
||||
@@ -3564,8 +3614,34 @@
|
||||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
loadIssueLabels(event.target.value);
|
||||
loadIssueMilestones(event.target.value);
|
||||
qs('#create-issue-repository-search').value = event.target.value;
|
||||
saveIssueCaptureDraft();
|
||||
scheduleIssueDuplicateCheck();
|
||||
updateIssueCreateActions();
|
||||
});
|
||||
qs('#create-issue-repository-search').addEventListener('input', event => {
|
||||
clearTimeout(issueRepositorySearchTimer);
|
||||
const query = event.target.value.trim();
|
||||
const results = qs('#create-issue-repository-results');
|
||||
const status = qs('#create-issue-repository-status');
|
||||
if (query.length < 2) {
|
||||
issueCapture.searchRepositories(query);
|
||||
results.hidden = true;
|
||||
status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Searching accessible repositories…';
|
||||
issueRepositorySearchTimer = setTimeout(async () => {
|
||||
const state = await issueCapture.searchRepositories(query);
|
||||
if (state.status === 'stale') return;
|
||||
if (state.status === 'failed') {
|
||||
results.hidden = true;
|
||||
status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.';
|
||||
return;
|
||||
}
|
||||
renderIssueRepositoryResults(state.items);
|
||||
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
|
||||
}, 250);
|
||||
});
|
||||
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
@@ -4224,6 +4300,7 @@
|
||||
});
|
||||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||||
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
||||
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
||||
qs('#retry-update-load').addEventListener('click', () => {
|
||||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||||
});
|
||||
@@ -4263,7 +4340,7 @@
|
||||
qs('#update-reply').focus();
|
||||
}
|
||||
});
|
||||
qs('#send-update-reply-next').addEventListener('click', async () => {
|
||||
qs('#send-update-reply-read-next').addEventListener('click', async () => {
|
||||
if (!selectedUpdate) return;
|
||||
const item = selectedUpdate;
|
||||
const body = qs('#update-reply').value.trim();
|
||||
@@ -4272,14 +4349,17 @@
|
||||
qs('#update-reply').focus();
|
||||
return;
|
||||
}
|
||||
const button = qs('#send-update-reply-next');
|
||||
const button = qs('#send-update-reply-read-next');
|
||||
const sendButton = qs('#send-update-reply');
|
||||
button.disabled = true;
|
||||
sendButton.disabled = true;
|
||||
qs('#update-reply-status').textContent = 'Sending reply…';
|
||||
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
||||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||||
try {
|
||||
await updateReplyNext.submit(item, body, operationId);
|
||||
const result = await updateReplyReadNext.submit(item, body, operationId);
|
||||
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||
'Reply and read acknowledgement queued for sync.';
|
||||
} catch (error) {
|
||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
qs('#update-reply').focus();
|
||||
|
||||
+5
-4
@@ -141,7 +141,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
@@ -161,11 +161,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(isClosure ? 'authorization' : 'queued')),
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure,
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure || (isReview && item.status === 'authorization'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
|
||||
+34
-6
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices">
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
@@ -38,6 +38,24 @@
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-activity-title">Security activity</h3>
|
||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||
</div>
|
||||
<div id="security-activity-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="security-activity-list" class="security-activity-list"></div>
|
||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -408,9 +426,15 @@
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-title">Title
|
||||
@@ -494,12 +518,15 @@
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<button id="send-update-reply-next" type="button" hidden>Reply & next</button>
|
||||
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</button>
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<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="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>
|
||||
@@ -672,6 +699,7 @@
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
|
||||
+124
-29
@@ -8,9 +8,64 @@
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') {
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
if (file.size <= MAX_BYTES) return file;
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') {
|
||||
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
||||
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => {
|
||||
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
|
||||
});
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
function multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const readDataUrl = options.readDataUrl;
|
||||
const upload = options.upload;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
@@ -21,13 +76,12 @@
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selectionGeneration = 0;
|
||||
|
||||
function select(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
}
|
||||
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
function commitSelection(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
@@ -36,7 +90,25 @@
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
const generation = ++selectionGeneration;
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
}
|
||||
if (!Number.isFinite(file.size) || file.size <= 0) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
});
|
||||
}
|
||||
return commitSelection(file);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
@@ -46,15 +118,16 @@
|
||||
function restore(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!data) {
|
||||
if (!blob && !data) {
|
||||
clear();
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size });
|
||||
serialized = { filename, contentType, data };
|
||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -69,14 +142,10 @@
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
const dataUrl = await readDataUrl(selected);
|
||||
const marker = ';base64,';
|
||||
const markerAt = String(dataUrl).indexOf(marker);
|
||||
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
data: String(dataUrl).slice(markerAt + marker.length),
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
@@ -92,7 +161,7 @@
|
||||
number: item.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
data: attachment.data,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: operationId,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
@@ -110,31 +179,56 @@
|
||||
const controller = create(options);
|
||||
const clearSelection = controller.clear;
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
options.input.disabled = false;
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
try {
|
||||
controller.select(file);
|
||||
} catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
function showPreview(file, optimized) {
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = options.createObjectURL(file);
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
const sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, false);
|
||||
return;
|
||||
}
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
return result.then(() => controller.serialize()).then(value => {
|
||||
if (sequence === selectionSequence) showPreview(value.blob, true);
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
}
|
||||
}).finally(() => {
|
||||
if (sequence === selectionSequence) options.input.disabled = false;
|
||||
});
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
@@ -144,7 +238,8 @@
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
|
||||
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
||||
'data:' + value.contentType + ';base64,' + value.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
@@ -155,5 +250,5 @@
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
|
||||
}
|
||||
|
||||
return { create, mount, MAX_BYTES };
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
});
|
||||
@@ -8,8 +8,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
function captureAttachment(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '').slice(0, 255);
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (blob) return { filename, contentType, blob };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
@@ -61,7 +63,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data) return item;
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
@@ -103,13 +105,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function hydrateForEdit(id) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return null;
|
||||
if (!item.attachment?.stored || item.attachment.data) return { ...item };
|
||||
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
|
||||
if (!backgroundSync?.get) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const durable = await backgroundSync.get(id);
|
||||
const attachment = captureAttachment(durable?.attachment);
|
||||
if (!attachment?.data || durable?.operationId !== item.operationId) {
|
||||
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
@@ -196,6 +198,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
async function sendDirect(item, repository) {
|
||||
let issue = item.deliveredIssue;
|
||||
if (!issue) {
|
||||
@@ -221,14 +236,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
|
||||
const focused = documentRef.activeElement;
|
||||
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
|
||||
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
@@ -59,12 +61,14 @@
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.composer.addEventListener('focus', entry.onFocus);
|
||||
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
|
||||
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
|
||||
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.composer.removeEventListener('focus', entry.onFocus);
|
||||
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
|
||||
+21
-13
@@ -292,6 +292,22 @@ function createNotificationReader({
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
@@ -327,6 +343,10 @@ function createNotificationReader({
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
@@ -354,19 +374,7 @@ function createNotificationReader({
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return await advanceAfterRead(items, current, queueing);
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v82';
|
||||
const CACHE = 'stackchain-dashboard-shell-v85';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -31,6 +31,7 @@ const SHELL = [
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
@@ -204,11 +205,13 @@ async function flushAndNotify() {
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const title = needsAttention
|
||||
const needsAuthorization = receipt.status === 'authorization';
|
||||
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
|
||||
? 'Queued work needs attention'
|
||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
||||
await self.registration.showNotification(title, {
|
||||
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
});
|
||||
|
||||
@@ -38,8 +38,14 @@
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
@@ -76,10 +82,112 @@
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
device_revoked: 'Device access revoked',
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [
|
||||
event.device_label,
|
||||
event.method,
|
||||
event.target,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||||
].filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
renderPasskeys();
|
||||
renderSecurityActivity();
|
||||
});
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
@@ -91,6 +199,7 @@
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
@@ -494,6 +603,39 @@
|
||||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||||
}
|
||||
|
||||
async function listPasskeys() {
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys');
|
||||
if (!response.ok) throw new Error('Could not load enrolled passkeys');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
|
||||
}
|
||||
|
||||
async function revokePasskey(passkey) {
|
||||
if (!passkey?.management_id) return false;
|
||||
const confirmed = confirmAction?.(
|
||||
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not remove enrolled passkey');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
|
||||
if (!response.ok) throw new Error('Could not load security activity');
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeActiveDevice(device) {
|
||||
if (!device?.management_id || device.current) return false;
|
||||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||||
@@ -537,8 +679,11 @@
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
function createUpdateOwnership({
|
||||
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
|
||||
}) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
@@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function startState(value) {
|
||||
onStartState?.(value);
|
||||
}
|
||||
|
||||
function claimed(result) {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
return claimedItem;
|
||||
}
|
||||
|
||||
function recoverStartedItem(item) {
|
||||
recover?.(item);
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return 'recovery';
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
@@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
startState({
|
||||
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
@@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
claimed(result);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
@@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act };
|
||||
function startNow() {
|
||||
if (request) return request;
|
||||
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
|
||||
if (!available()) {
|
||||
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
|
||||
startState({ action: 'start', busy: false, message });
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
|
||||
state({ action: 'claim', busy: true, message: '' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
const item = claimed(result);
|
||||
const outcome = start(item);
|
||||
if (outcome === 'started') {
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return outcome;
|
||||
}
|
||||
return recoverStartedItem(item);
|
||||
}).catch(error => {
|
||||
if (claimedItem) {
|
||||
return recoverStartedItem(claimedItem);
|
||||
}
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({ action: 'hidden', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({ action: 'claim', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'start',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act, start:startNow };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -0,0 +1,50 @@
|
||||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admit(item, body, operationId, replyConfirmed) {
|
||||
const admission = await queue({
|
||||
kind: 'update-reply-read',
|
||||
notificationId: item.notification_id,
|
||||
body,
|
||||
operationId,
|
||||
replyConfirmed,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||
}
|
||||
accept(item);
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
next: await next(item),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
await markRead(item.notification_id);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
return await admit(item, body, operationId, replyConfirmed);
|
||||
}
|
||||
accept(item);
|
||||
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
rjsmin==1.2.5
|
||||
uvicorn==0.41.0
|
||||
|
||||
+42
-6
@@ -13,6 +13,9 @@ import rjsmin
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,6 +25,7 @@ class FrontendBuild:
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
shell_digest: str
|
||||
page_sources: tuple[str, ...]
|
||||
service_worker_source: str
|
||||
|
||||
@@ -61,11 +65,42 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
||||
shell_hasher = hashlib.sha256()
|
||||
shell_inputs = (
|
||||
("dashboard.html", dashboard_html.encode()),
|
||||
(runtime_name, runtime),
|
||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||
(
|
||||
"static/icons/stackchain-192.png",
|
||||
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
"static/icons/stackchain-512.png",
|
||||
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
WORKER_RUNTIME_SOURCE,
|
||||
(
|
||||
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
|
||||
).read_bytes(),
|
||||
),
|
||||
("service-worker.js", worker.encode()),
|
||||
)
|
||||
for name, content in shell_inputs:
|
||||
shell_hasher.update(len(name).to_bytes(4, "big"))
|
||||
shell_hasher.update(name.encode())
|
||||
shell_hasher.update(len(content).to_bytes(8, "big"))
|
||||
shell_hasher.update(content)
|
||||
shell_digest = shell_hasher.hexdigest()[:16]
|
||||
worker = worker.replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
|
||||
1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
@@ -74,6 +109,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
runtime_gzip_bytes=runtime_gzip,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
shell_digest=shell_digest,
|
||||
page_sources=sources,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
)
|
||||
@@ -301,6 +301,21 @@ async def repos() -> RepositoryItems:
|
||||
)
|
||||
|
||||
|
||||
async def search_repositories(query: str, limit: int = 20) -> list[dict]:
|
||||
"""Search repositories visible to the authenticated Gitea user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/search",
|
||||
headers=_auth(),
|
||||
params={"q": query.strip(), "limit": limit, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("Gitea repository search response was not a list")
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
|
||||
|
||||
async def repository_access(repository: str) -> dict | None:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
response = await _get_client().get(
|
||||
|
||||
+460
-61
@@ -19,7 +19,8 @@ from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Re
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||
from pydantic import BaseModel, Field, PositiveInt, ValidationError, field_validator, model_validator
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from src import dashboard_auth, gitea_proxy, passkeys
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
@@ -42,6 +43,7 @@ from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, Refres
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
@@ -198,15 +200,20 @@ class DashboardSignIn(BaseModel):
|
||||
return normalized
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
"revoke_passkey",
|
||||
]
|
||||
|
||||
|
||||
class FreshAuthorization(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
action: Literal[
|
||||
"merge_pull",
|
||||
"close_issue",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
]
|
||||
action: StepUpAction
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -224,9 +231,7 @@ class PasskeyAuthentication(PasskeyCeremony):
|
||||
|
||||
|
||||
class PasskeyAuthorizationTarget(BaseModel):
|
||||
action: Literal[
|
||||
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
|
||||
]
|
||||
action: StepUpAction
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -242,6 +247,15 @@ def _passkey_store() -> PasskeyStore:
|
||||
return PasskeyStore(database, clock=time.time)
|
||||
|
||||
|
||||
def _security_event_store() -> SecurityEventStore:
|
||||
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
||||
database = os.getenv(
|
||||
"STACKCHAIN_SECURITY_EVENT_DB",
|
||||
os.path.join(state_dir, "security-events.sqlite3"),
|
||||
)
|
||||
return SecurityEventStore(database, clock=time.time)
|
||||
|
||||
|
||||
def _passkey_relying_party(request: Request) -> tuple[str, str]:
|
||||
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
|
||||
origin = os.getenv(
|
||||
@@ -359,6 +373,34 @@ class IssueComment(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
|
||||
expected = {
|
||||
"image/png": {"png"},
|
||||
"image/jpeg": {"jpg", "jpeg"},
|
||||
"image/webp": {"webp"},
|
||||
}
|
||||
if content_type not in expected:
|
||||
raise ValueError("attachment content type must be PNG, JPEG, or WebP")
|
||||
if "/" in filename or "\\" in filename or any(ord(character) < 32 for character in filename):
|
||||
raise ValueError("attachment filename must be a plain file name")
|
||||
extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if extension not in expected[content_type]:
|
||||
raise ValueError("attachment filename must match the selected image type")
|
||||
|
||||
|
||||
def _validate_attachment_content(content_type: str, content: bytes) -> bytes:
|
||||
if len(content) > 2 * 1024 * 1024:
|
||||
raise ValueError("screenshot must be 2 MB or smaller")
|
||||
signatures = {
|
||||
"image/png": content.startswith(b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg": content.startswith(b"\xff\xd8\xff"),
|
||||
"image/webp": content.startswith(b"RIFF") and content[8:12] == b"WEBP",
|
||||
}
|
||||
if not signatures.get(content_type, False):
|
||||
raise ValueError("file contents do not match the selected image type")
|
||||
return content
|
||||
|
||||
|
||||
class IssueAttachment(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=255)
|
||||
content_type: Literal["image/png", "image/jpeg", "image/webp"]
|
||||
@@ -366,20 +408,7 @@ class IssueAttachment(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_filename(self):
|
||||
if (
|
||||
"/" in self.filename
|
||||
or "\\" in self.filename
|
||||
or any(ord(character) < 32 for character in self.filename)
|
||||
):
|
||||
raise ValueError("attachment filename must be a plain file name")
|
||||
extension = self.filename.rsplit(".", 1)[-1].lower() if "." in self.filename else ""
|
||||
expected = {
|
||||
"image/png": {"png"},
|
||||
"image/jpeg": {"jpg", "jpeg"},
|
||||
"image/webp": {"webp"},
|
||||
}
|
||||
if extension not in expected[self.content_type]:
|
||||
raise ValueError("attachment filename must match the selected image type")
|
||||
_validate_attachment_metadata(self.filename, self.content_type)
|
||||
return self
|
||||
|
||||
def content(self) -> bytes:
|
||||
@@ -387,16 +416,12 @@ class IssueAttachment(BaseModel):
|
||||
content = base64.b64decode(self.data, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise ValueError("attachment data must be valid base64") from exc
|
||||
if len(content) > 2 * 1024 * 1024:
|
||||
raise ValueError("screenshot must be 2 MB or smaller")
|
||||
signatures = {
|
||||
"image/png": content.startswith(b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg": content.startswith(b"\xff\xd8\xff"),
|
||||
"image/webp": content.startswith(b"RIFF") and content[8:12] == b"WEBP",
|
||||
}
|
||||
if not signatures[self.content_type]:
|
||||
raise ValueError("file contents do not match the selected image type")
|
||||
return content
|
||||
return _validate_attachment_content(self.content_type, content)
|
||||
|
||||
|
||||
def _validate_binary_attachment(filename: str, content_type: str, content: bytes) -> bytes:
|
||||
_validate_attachment_metadata(filename, content_type)
|
||||
return _validate_attachment_content(content_type, content)
|
||||
|
||||
|
||||
class IssueCreation(BaseModel):
|
||||
@@ -809,7 +834,7 @@ async def require_operator_session(request: Request, call_next):
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later"} or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -936,6 +961,21 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
method="token",
|
||||
device_label=payload.device_label,
|
||||
target="dashboard",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
return JSONResponse(
|
||||
{"detail": "Security activity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
max_age = max(1, session.expires_at - int(time.time()))
|
||||
response.set_cookie(
|
||||
@@ -960,6 +1000,39 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@app.get("/api/v1/security-events")
|
||||
async def list_security_events(
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
cursor: int | None = Query(default=None, ge=1),
|
||||
):
|
||||
try:
|
||||
page = await asyncio.to_thread(
|
||||
_security_event_store().list, limit=limit, cursor=cursor
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Security activity is temporarily unavailable"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": event.id,
|
||||
"kind": event.kind,
|
||||
"method": event.method,
|
||||
"device_label": event.device_label,
|
||||
"target": event.target,
|
||||
"created_at": event.created_at,
|
||||
"status": event.status,
|
||||
}
|
||||
for event in page.events
|
||||
],
|
||||
"next_cursor": page.next_cursor,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/fresh-authorization", status_code=201)
|
||||
async def fresh_authorization(payload: FreshAuthorization, request: Request):
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
@@ -1102,6 +1175,120 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/passkeys")
|
||||
async def list_enrolled_passkeys(request: Request):
|
||||
try:
|
||||
credentials = await asyncio.to_thread(_passkey_store().all)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Passkey registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
active = {device.management_id: device for device in devices}
|
||||
return JSONResponse(
|
||||
{
|
||||
"passkeys": [
|
||||
{
|
||||
"management_id": credential.management_id,
|
||||
"device_label": credential.device_label,
|
||||
"created_at": credential.created_at,
|
||||
"active": credential.management_id in active,
|
||||
"current": bool(
|
||||
credential.management_id in active
|
||||
and active[credential.management_id].current
|
||||
),
|
||||
}
|
||||
for credential in credentials
|
||||
]
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/v1/passkeys/{management_id}")
|
||||
async def revoke_enrolled_passkey(
|
||||
request: Request,
|
||||
management_id: str = PathParam(
|
||||
min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"
|
||||
),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="revoke_passkey",
|
||||
target=management_id,
|
||||
)
|
||||
store = _passkey_store()
|
||||
try:
|
||||
credential = await asyncio.to_thread(store.get_management_id, management_id)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Passkey registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if credential is None:
|
||||
raise HTTPException(status_code=404, detail="Enrolled passkey not found")
|
||||
active_device = next(
|
||||
(device for device in devices if device.management_id == management_id), None
|
||||
)
|
||||
current_session = bool(active_device and active_device.current)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_revoked",
|
||||
device_label=credential.device_label,
|
||||
target="passkey",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Security activity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
revoked, session_revoked = await asyncio.to_thread(
|
||||
store.revoke_access,
|
||||
management_id,
|
||||
preserve_session=current_session,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"detail": "Passkey registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if not revoked:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise HTTPException(status_code=404, detail="Enrolled passkey not found")
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{
|
||||
"revoked": True,
|
||||
"current_session": current_session,
|
||||
"session_revoked": session_revoked,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/passkeys/authentication/options")
|
||||
async def create_passkey_authentication_options(request: Request):
|
||||
store = _passkey_store()
|
||||
@@ -1248,6 +1435,19 @@ async def verify_passkey_authentication(
|
||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="dashboard",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Security activity is temporarily unavailable"
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
max_age = max(1, session.expires_at - int(time.time()))
|
||||
response.set_cookie(
|
||||
@@ -1426,14 +1626,38 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
@app.delete("/api/v1/session")
|
||||
async def sign_out(request: Request, response: Response):
|
||||
session = request.state.dashboard_session
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"sign_out",
|
||||
target="current_device",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Security activity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
# The pending reservation is durable evidence; do not report a
|
||||
# successful revocation as failed merely because completion could not
|
||||
# be marked yet.
|
||||
pass
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.SESSION_COOKIE,
|
||||
@@ -1499,16 +1723,45 @@ async def revoke_active_device(
|
||||
target = next(
|
||||
(device for device in devices if device.management_id == management_id), None
|
||||
)
|
||||
await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
|
||||
revoked = await dashboard_auth.revoke_managed_session(management_id)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if target is None or not revoked:
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Active device not found")
|
||||
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"device_revoked",
|
||||
device_label=target.device_label,
|
||||
target="device",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Security activity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
revoked = await asyncio.to_thread(
|
||||
_passkey_store().revoke_device_access, management_id
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if not revoked:
|
||||
raise HTTPException(status_code=404, detail="Active device not found")
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return {"revoked": True, "current_session": target.current}
|
||||
|
||||
|
||||
@@ -1526,15 +1779,31 @@ async def sign_out_all_devices(
|
||||
action="revoke_all_sessions",
|
||||
target="all",
|
||||
)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
await asyncio.to_thread(_passkey_store().revoke_all)
|
||||
await dashboard_auth.revoke_all_sessions()
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"all_sessions_revoked",
|
||||
target="all_devices",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Security activity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(_passkey_store().revoke_all_access)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.SESSION_COOKIE,
|
||||
@@ -1644,6 +1913,35 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories/search")
|
||||
async def repository_search(
|
||||
q: str = Query(min_length=2, max_length=80),
|
||||
limit: int = Query(default=20, ge=1, le=20),
|
||||
) -> JSONResponse:
|
||||
"""Return minimal repository identities matching a bounded visible-repo search."""
|
||||
query = q.strip()
|
||||
if len(query) < 2:
|
||||
return JSONResponse({"error": "Enter at least 2 search characters."}, status_code=400)
|
||||
try:
|
||||
matches = await asyncio.wait_for(
|
||||
gitea_proxy.search_repositories(query, limit),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Repository search is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
items = [
|
||||
{"id": item["id"], "name": item["name"], "full_name": item["full_name"]}
|
||||
for item in matches
|
||||
if isinstance(item, dict)
|
||||
and all(field in item for field in ("id", "name", "full_name"))
|
||||
]
|
||||
return JSONResponse({"items": items}, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories")
|
||||
async def repository_page(
|
||||
page: int = Query(default=1, ge=1, le=1000),
|
||||
@@ -3078,7 +3376,7 @@ async def comment_on_assigned_issue(
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||
async def attach_to_assigned_issue(
|
||||
attachment: IssueAttachment,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
@@ -3086,15 +3384,27 @@ async def attach_to_assigned_issue(
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
content = attachment.content()
|
||||
except ValueError as exc:
|
||||
if request.headers.get("content-type", "").lower().startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
uploaded = form.get("file")
|
||||
if not isinstance(uploaded, UploadFile):
|
||||
raise ValueError("screenshot file is required")
|
||||
filename = str(uploaded.filename or "")
|
||||
content_type = str(uploaded.content_type or "")
|
||||
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
|
||||
else:
|
||||
attachment = IssueAttachment.model_validate(await request.json())
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
async def upload_attachment():
|
||||
result = await gitea_proxy.upload_assigned_issue_attachment(
|
||||
repository,
|
||||
number,
|
||||
attachment.filename,
|
||||
attachment.content_type,
|
||||
filename,
|
||||
content_type,
|
||||
content,
|
||||
)
|
||||
safe_name = (
|
||||
@@ -3113,8 +3423,8 @@ async def attach_to_assigned_issue(
|
||||
"issue-attachment",
|
||||
repository,
|
||||
number,
|
||||
attachment.filename,
|
||||
attachment.content_type,
|
||||
filename,
|
||||
content_type,
|
||||
hashlib.sha256(content).hexdigest(),
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
@@ -3150,13 +3460,20 @@ async def close_assigned_issue(
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
|
||||
async def close_issue():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.close_issue(repository, number)
|
||||
|
||||
target = f"{repository}#{number}"
|
||||
try:
|
||||
return await asyncio.wait_for(close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
assigned = await asyncio.wait_for(
|
||||
gitea_proxy.is_assigned_issue(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not assigned:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
journal = _security_event_store()
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"issue_closed",
|
||||
target=target,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -3166,6 +3483,30 @@ async def close_assigned_issue(
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.close_issue(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "The issue could not be closed. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
# The close result is authoritative; the pending reservation preserves
|
||||
# evidence without telling the operator the issue remains open.
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
|
||||
async def assigned_issue_label_options(
|
||||
@@ -3405,9 +3746,15 @@ async def merge_assigned_pull(
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
result = await asyncio.wait_for(
|
||||
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"pull_merged",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
@@ -3448,12 +3795,26 @@ async def merge_assigned_pull(
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if submission.decision != "comment":
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="submit_pull_review",
|
||||
target=(
|
||||
f"{repository}#{number}@{submission.expected_head_sha}:"
|
||||
f"{submission.decision}"
|
||||
),
|
||||
)
|
||||
|
||||
async def submit_requested_review():
|
||||
if not await is_requested_review(repository, number):
|
||||
@@ -3465,12 +3826,50 @@ async def submit_review(
|
||||
submission.decision,
|
||||
submission.body,
|
||||
)
|
||||
if submission.comments:
|
||||
return await gitea_proxy.submit_pull_review(
|
||||
*args,
|
||||
[comment.model_dump() for comment in submission.comments],
|
||||
)
|
||||
return await gitea_proxy.submit_pull_review(*args)
|
||||
journal = None
|
||||
operation_id = None
|
||||
if submission.decision != "comment":
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
(
|
||||
"pull_review_approved"
|
||||
if submission.decision == "approve"
|
||||
else "pull_review_changes_requested"
|
||||
),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Security activity is temporarily unavailable",
|
||||
)
|
||||
try:
|
||||
if submission.comments:
|
||||
result = await gitea_proxy.submit_pull_review(
|
||||
*args,
|
||||
[comment.model_dump() for comment in submission.comments],
|
||||
)
|
||||
else:
|
||||
result = await gitea_proxy.submit_pull_review(*args)
|
||||
except (
|
||||
HTTPException,
|
||||
gitea_proxy.StaleReviewError,
|
||||
gitea_proxy.InvalidReviewCommentError,
|
||||
):
|
||||
if journal is not None and operation_id is not None:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
if journal is not None and operation_id is not None:
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return result
|
||||
|
||||
try:
|
||||
comment_fingerprint = tuple(
|
||||
|
||||
+87
-4
@@ -16,6 +16,7 @@ class StoredPasskey:
|
||||
sign_count: int
|
||||
device_label: str
|
||||
management_id: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
@@ -150,7 +151,7 @@ class PasskeyStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id "
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -161,7 +162,7 @@ class PasskeyStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id "
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE credential_id = ?",
|
||||
(credential_id,),
|
||||
).fetchone()
|
||||
@@ -169,6 +170,18 @@ class PasskeyStore:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def get_management_id(self, management_id: str) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
@@ -181,12 +194,48 @@ class PasskeyStore:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_management_id(self, management_id: str) -> None:
|
||||
def revoke_management_id(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_access(
|
||||
self, management_id: str, *, preserve_session: bool
|
||||
) -> tuple[bool, bool]:
|
||||
"""Atomically remove one passkey and, when requested, its active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
credential = connection.execute(
|
||||
"SELECT 1 FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if credential is None:
|
||||
return False, False
|
||||
session_revoked = False
|
||||
if not preserve_session:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is not None:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
)
|
||||
session_revoked = cursor.rowcount == 1
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
)
|
||||
return cursor.rowcount == 1, session_revoked
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
@@ -197,3 +246,37 @@ class PasskeyStore:
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_device_access(self, management_id: str) -> bool:
|
||||
"""Atomically remove one active session, its grants, and its linked passkey."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self) -> None:
|
||||
"""Atomically remove every passkey, challenge, grant, and active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
+14
-1
@@ -5,7 +5,8 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -50,6 +51,11 @@ class RequestBodyLimitMiddleware:
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if (
|
||||
limit == ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
and self._header(scope, b"content-type").startswith(b"application/json")
|
||||
):
|
||||
limit = LEGACY_JSON_ATTACHMENT_BODY_LIMIT
|
||||
|
||||
declared_length = self._content_length(scope)
|
||||
if declared_length is not None and declared_length > limit:
|
||||
@@ -77,6 +83,13 @@ class RequestBodyLimitMiddleware:
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
||||
@staticmethod
|
||||
def _header(scope: Scope, wanted: bytes) -> bytes:
|
||||
for name, value in scope.get("headers", []):
|
||||
if name.lower() == wanted:
|
||||
return value.lower()
|
||||
return b""
|
||||
|
||||
@staticmethod
|
||||
def _content_length(scope: Scope) -> int | None:
|
||||
for name, value in scope.get("headers", []):
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Bounded, privacy-preserving journal of operator security activity."""
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
"""Raised when security activity cannot be persisted or read safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecurityEvent:
|
||||
id: int
|
||||
kind: str
|
||||
method: str | None
|
||||
device_label: str | None
|
||||
target: str | None
|
||||
created_at: int
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecurityEventPage:
|
||||
events: list[SecurityEvent]
|
||||
next_cursor: int | None
|
||||
|
||||
|
||||
class SecurityEventStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_events: int = 10_000,
|
||||
retention_seconds: int = 90 * 24 * 60 * 60,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_events = max(1, max_events)
|
||||
self.retention_seconds = max(1, retention_seconds)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _bounded(value: str | None, limit: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = " ".join(str(value).split())[:limit]
|
||||
return normalized or None
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT,
|
||||
device_label TEXT,
|
||||
target TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
if "status" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
|
||||
)
|
||||
if "operation_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE created_at < ?",
|
||||
(now - self.retention_seconds,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE id NOT IN "
|
||||
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
|
||||
(self.max_events,),
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def reserve(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
operation_id = secrets.token_urlsafe(24)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
operation_id,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
return operation_id
|
||||
|
||||
def finalize(self, operation_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
|
||||
(operation_id,),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity reservation was not found"
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def discard(self, operation_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
|
||||
(operation_id,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
if cursor is not None:
|
||||
where = "WHERE id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, kind, method, device_label, target, created_at, status "
|
||||
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
has_more = len(rows) > bounded_limit
|
||||
visible = rows[:bounded_limit]
|
||||
return SecurityEventPage(
|
||||
events=[SecurityEvent(*row) for row in visible],
|
||||
next_cursor=visible[-1][0] if has_more else None,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
|
||||
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
sessions.activate(
|
||||
"phone-session",
|
||||
2_000,
|
||||
management_id="phone-management-id",
|
||||
device_label="Phone",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
"phone-session",
|
||||
action="revoke_all_sessions",
|
||||
target="all",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
passkeys.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
passkeys.issue_challenge(
|
||||
b"pending-challenge",
|
||||
session_id="phone-session",
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_session_delete BEFORE DELETE ON active_sessions "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_all_access()
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for label in ("Phone", "Laptop"):
|
||||
slug = label.lower()
|
||||
sessions.activate(
|
||||
f"{slug}-session",
|
||||
2_000,
|
||||
management_id=f"{slug}-management-id",
|
||||
device_label=label,
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"{slug}-session",
|
||||
action="close_issue",
|
||||
target="stackchain/dashboard#1",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"{slug}-credential".encode(),
|
||||
public_key=f"{slug}-public-key".encode(),
|
||||
sign_count=0,
|
||||
device_label=label,
|
||||
management_id=f"{slug}-management-id",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_phone_delete BEFORE DELETE ON active_sessions "
|
||||
"WHEN OLD.management_id = 'phone-management-id' "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_device_access("phone-management-id")
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM active_sessions ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
|
||||
@@ -480,6 +480,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authorization_hold_can_resume_in_foreground_with_original_review_identity():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const sent=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}},
|
||||
send:async item=>{{sent.push({{operationId:item.operationId,status:item.status,
|
||||
head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable',
|
||||
body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}});
|
||||
outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization',
|
||||
error:'Fresh authorization required'}}]);
|
||||
(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["held"]["status"] == "authorization"
|
||||
assert output["sent"] == [{
|
||||
"operationId": "review-stable", "status": "authorization",
|
||||
"head": "abc123", "decision": "approve",
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"id": 91}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
@@ -498,7 +527,7 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
assert "authoredOutbox.reconcileBackground(records)" in html
|
||||
|
||||
|
||||
def test_screenshot_comment_admits_bytes_to_indexeddb_without_putting_them_in_localstorage():
|
||||
def test_screenshot_comment_admits_blob_to_indexeddb_without_putting_bytes_in_localstorage():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors=[];
|
||||
@@ -508,12 +537,15 @@ const outbox=createAuthoredOutbox({{
|
||||
backgroundSync:{{reconcile:async(items,lane)=>mirrors.push({{items,lane}}),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const blob=new Blob(['PRIVATE-IMAGE-BYTES'],{{type:'image/png'}});
|
||||
const admission=await outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:477,
|
||||
body:'Broken at 320px',operationId:'comment-image-477',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'PRIVATE-IMAGE-BYTES'}},
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),mirrored:mirrors[0]}}));
|
||||
const durable=mirrors[0].items[0].attachment;
|
||||
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),
|
||||
mirrored:{{lane:mirrors[0].lane,isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}}}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
@@ -523,8 +555,9 @@ const outbox=createAuthoredOutbox({{
|
||||
assert local_item["attachment"] == {
|
||||
"filename": "phone.png", "contentType": "image/png", "stored": True
|
||||
}
|
||||
assert output["mirrored"]["lane"] == "authored"
|
||||
assert output["mirrored"]["items"][0]["attachment"]["data"] == "PRIVATE-IMAGE-BYTES"
|
||||
assert output["mirrored"] == {
|
||||
"lane": "authored", "isBlob": True, "size": 19, "text": "PRIVATE-IMAGE-BYTES"
|
||||
}
|
||||
assert output["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
|
||||
@@ -82,7 +82,10 @@ const store = {{
|
||||
}};
|
||||
let uploadAttempts=0;
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
|
||||
@@ -113,6 +116,37 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const blob=new Blob(['offline-binary'],{{type:'image/png'}});
|
||||
const item={{id:'binary',operationId:'binary',ownerLogin:'timmy',repository:'o/r',title:'Visual',body:'',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}}}};
|
||||
let queued=true;
|
||||
const observed={{}};
|
||||
const store={{claimNext:async()=>queued?(queued=false,{{...item}}):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{number:7}};
|
||||
if(url.endsWith('/attachments')){{const file=options.body.get('file');Object.assign(observed,{{
|
||||
isFormData:options.body instanceof FormData,contentTypeHeader:options.headers['Content-Type']||null,
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}});return{{markdown:''}};}}
|
||||
return{{id:8}};
|
||||
}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(observed));}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"isFormData": True,
|
||||
"contentTypeHeader": None,
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "offline-binary",
|
||||
}
|
||||
|
||||
|
||||
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -175,7 +209,10 @@ const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transfor
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('Comment unavailable');error.status=503;throw error;}}
|
||||
return{{id:91}};
|
||||
@@ -372,6 +409,41 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_holds_consequential_review_for_foreground_authorization():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc123',comments:[]}};
|
||||
const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false;
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,item),
|
||||
authorization:async(id,error)=>state.authorized.push({{id,error}}),
|
||||
fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id),
|
||||
complete:async id=>state.completed.push(id),countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const error=new Error('Fresh authorization required');error.status=428;
|
||||
error.code='step_up_required';throw error;
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {
|
||||
"authorized": [{"id": "review-op", "error": "Fresh authorization required"}],
|
||||
"failed": [], "released": [], "completed": [],
|
||||
}
|
||||
assert output["result"]["attention"] == 0
|
||||
assert output["result"]["authorization"] == 1
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "review-op", "status": "authorization", "kind": "message",
|
||||
"route": "#/my-work/review/stackchain/web/8",
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
|
||||
records = [
|
||||
{
|
||||
@@ -435,7 +507,7 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_bytes():
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_blob():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
@@ -447,21 +519,23 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',data:'first-image-bytes'}},
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'second-image-bytes'}}}},
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(await store.snapshot()));
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output[0]["attachment"]["data"] == "first-image-bytes"
|
||||
assert output[1]["attachment"]["data"] == "second-image-bytes"
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
|
||||
@@ -292,17 +292,15 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_current_today_update_offers_reply_and_next_without_marking_read():
|
||||
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-next"' in html
|
||||
assert '>Reply & next</button>' in html
|
||||
assert "setUpdateReplyNextVisibility();" in html
|
||||
assert "const updateReplyNext = createCommentNext({" in html
|
||||
assert "notificationReplier.saveDraft(item, '');" in html
|
||||
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
|
||||
assert "qs('#mark-update-read-next').click()" not in html
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
|
||||
assert "markRead: markNotificationRead" in html
|
||||
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v82" in worker
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
@@ -17,6 +17,7 @@ def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security-events.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
||||
@@ -136,6 +137,7 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
activity = await returning.get("/api/v1/security-events")
|
||||
|
||||
assert enrolled.status_code == 201
|
||||
assert enrolled.json() == {"enrolled": True}
|
||||
@@ -143,6 +145,15 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
assert sign_in_options.json()["allowCredentials"][0]["id"] == "cGhvbmUtY3JlZGVudGlhbA"
|
||||
assert signed_in.status_code == 200
|
||||
assert signed_in.json() == {"authenticated": True, "method": "passkey"}
|
||||
assert activity.json()["events"][0] == {
|
||||
"id": activity.json()["events"][0]["id"],
|
||||
"kind": "sign_in",
|
||||
"method": "passkey",
|
||||
"device_label": "Phone",
|
||||
"target": "dashboard",
|
||||
"created_at": activity.json()["events"][0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
assert "stackchain_session=" in signed_in.headers["set-cookie"]
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
@@ -190,6 +201,14 @@ async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
json={"action": "close_issue", "target": "stackchain/api#7"},
|
||||
headers=headers,
|
||||
)
|
||||
review_options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
ceremony = {
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
@@ -209,6 +228,8 @@ async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
)
|
||||
|
||||
assert options.status_code == 200
|
||||
assert review_options.status_code == 200
|
||||
assert review_options.json()["challenge"]
|
||||
assert authorized.status_code == 201
|
||||
assert authorized.json()["grant"]
|
||||
assert authorized.json()["expires_in"] == 90
|
||||
@@ -272,6 +293,253 @@ async def test_revoking_an_enrolled_device_also_revokes_its_passkey(
|
||||
assert passkey_options.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_device_revocation_failure_preserves_its_passkey_and_session(
|
||||
access_control,
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
phone_device = next(
|
||||
item
|
||||
for item in (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
if item["device_label"] == "Phone"
|
||||
)
|
||||
store = main._passkey_store()
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=phone_device["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_device", phone_device["management_id"]
|
||||
)
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_phone_delete BEFORE DELETE ON active_sessions "
|
||||
f"WHEN OLD.management_id = '{phone_device['management_id']}' "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
response = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_session = await phone.get("/api/v1/session")
|
||||
passkeys = await laptop.get("/api/v1/passkeys")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert phone_session.status_code == 200
|
||||
assert phone_session.json()["authenticated"] is True
|
||||
assert [item["device_label"] for item in passkeys.json()["passkeys"]] == ["Phone"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orphaned_passkey_is_listed_safely_and_can_be_selectively_revoked(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"old-phone-credential"
|
||||
credential_public_key = b"old-phone-public-key"
|
||||
sign_count = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as phone:
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Old phone"},
|
||||
)
|
||||
phone_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
}
|
||||
enrollment_grant = await fresh_grant(phone, "enroll_passkey", "current_device")
|
||||
registration = await phone.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**phone_headers, "X-Step-Up-Grant": enrollment_grant},
|
||||
)
|
||||
enrolled = await phone.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=phone_headers,
|
||||
)
|
||||
await phone.delete("/api/v1/session", headers=phone_headers)
|
||||
|
||||
store = main._passkey_store()
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=b"backup-credential",
|
||||
public_key=b"backup-public-key",
|
||||
sign_count=0,
|
||||
device_label="Backup key",
|
||||
management_id="backup-management-id",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as laptop:
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
listed = await laptop.get("/api/v1/passkeys")
|
||||
old_phone = next(
|
||||
item for item in listed.json()["passkeys"] if item["device_label"] == "Old phone"
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing_grant = await laptop.delete(
|
||||
f"/api/v1/passkeys/{old_phone['management_id']}", headers=headers
|
||||
)
|
||||
grant = await fresh_grant(laptop, "revoke_passkey", old_phone["management_id"])
|
||||
removed = await laptop.delete(
|
||||
f"/api/v1/passkeys/{old_phone['management_id']}",
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
remaining = await laptop.get("/api/v1/passkeys")
|
||||
sign_in_options = await laptop.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert enrolled.status_code == 201
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert set(old_phone) == {
|
||||
"management_id", "device_label", "created_at", "active", "current"
|
||||
}
|
||||
assert old_phone["active"] is False
|
||||
assert old_phone["current"] is False
|
||||
assert b"old-phone-credential" not in listed.content
|
||||
assert b"old-phone-public-key" not in listed.content
|
||||
assert missing_grant.status_code == 428
|
||||
assert missing_grant.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "revoke_passkey",
|
||||
"target": old_phone["management_id"],
|
||||
}
|
||||
assert removed.status_code == 200
|
||||
assert removed.json() == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert [item["device_label"] for item in remaining.json()["passkeys"]] == ["Backup key"]
|
||||
assert sign_in_options.status_code == 200
|
||||
assert sign_in_options.json()["allowCredentials"] == [
|
||||
{"id": "YmFja3VwLWNyZWRlbnRpYWw", "type": "public-key"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_removing_a_remote_passkey_also_revokes_its_active_session(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
phone_device = next(item for item in devices if item["device_label"] == "Phone")
|
||||
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_device["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_passkey", phone_device["management_id"]
|
||||
)
|
||||
removed = await laptop.delete(
|
||||
f"/api/v1/passkeys/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_status = await phone.get("/api/v1/session")
|
||||
laptop_status = await laptop.get("/api/v1/session")
|
||||
|
||||
assert removed.json() == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": True,
|
||||
}
|
||||
assert phone_status.status_code == 401
|
||||
assert phone_status.json()["code"] == "session_revoked"
|
||||
assert laptop_status.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_removing_the_current_passkey_keeps_the_current_session_active(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
current = next(
|
||||
item for item in (await client.get("/api/v1/sessions")).json()["devices"]
|
||||
if item["current"]
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"current-credential",
|
||||
public_key=b"current-public-key",
|
||||
sign_count=0,
|
||||
device_label="Laptop",
|
||||
management_id=current["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(client, "revoke_passkey", current["management_id"])
|
||||
removed = await client.delete(
|
||||
f"/api/v1/passkeys/{current['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
still_active = await client.get("/api/v1/session")
|
||||
activity = await client.get("/api/v1/security-events")
|
||||
|
||||
assert removed.json() == {
|
||||
"revoked": True,
|
||||
"current_session": True,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert still_active.status_code == 200
|
||||
assert activity.json()["events"][0]["kind"] == "passkey_revoked"
|
||||
assert activity.json()["events"][0]["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
@@ -695,6 +963,91 @@ async def test_other_high_impact_routes_require_fresh_authorization_before_mutat
|
||||
assert laptop_still_active.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_consequential_review_requires_decision_and_head_bound_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
calls.append(("requested", repository, number))
|
||||
return True
|
||||
|
||||
async def submit(repository, number, head, decision, body):
|
||||
calls.append(("submit", repository, number, head, decision, body))
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready to ship.",
|
||||
}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
wrong_grant = await fresh_grant(
|
||||
client,
|
||||
"submit_pull_review",
|
||||
"stackchain/api#7@different:approve",
|
||||
)
|
||||
mismatched = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": wrong_grant},
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
client,
|
||||
"submit_pull_review",
|
||||
"stackchain/api#7@abc123:approve",
|
||||
)
|
||||
approved = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
comment = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={**payload, "decision": "comment"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [missing.status_code, mismatched.status_code] == [428, 428]
|
||||
assert missing.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
}
|
||||
assert approved.status_code == 201
|
||||
assert replayed.status_code == 428
|
||||
assert comment.status_code == 201
|
||||
assert calls == [
|
||||
("requested", "stackchain/api", 7),
|
||||
("submit", "stackchain/api", 7, "abc123", "approve", "Ready to ship."),
|
||||
("requested", "stackchain/api", 7),
|
||||
("submit", "stackchain/api", 7, "abc123", "comment", "Ready to ship."),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_can_review_and_revoke_one_remote_device(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -899,16 +1252,10 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
|
||||
class BrokenStore:
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
return "active"
|
||||
|
||||
def consume_step_up(self, grant, session_id, *, action, target):
|
||||
return True
|
||||
|
||||
def revoke_all(self):
|
||||
def revoke_all_access(self):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
monkeypatch.setattr(main, "_passkey_store", lambda: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
@@ -925,6 +1272,49 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_failure_preserves_passkeys_and_sessions(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
device = (await client.get("/api/v1/sessions")).json()["devices"][0]
|
||||
store = main._passkey_store()
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=device["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_session_delete BEFORE DELETE ON active_sessions "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
session = await client.get("/api/v1/session")
|
||||
passkeys = await client.get("/api/v1/passkeys")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert session.status_code == 200
|
||||
assert session.json()["authenticated"] is True
|
||||
assert passkeys.status_code == 200
|
||||
assert [item["device_label"] for item in passkeys.json()["passkeys"]] == ["Phone"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -596,6 +596,72 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["confirmations"] == ["Sign out Pixel <script>?"]
|
||||
|
||||
|
||||
def test_enrolled_passkeys_can_be_loaded_and_selectively_removed():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responses = [
|
||||
{status:200,payload:{passkeys:[
|
||||
{management_id:'passkey-id-12345678', device_label:'Old phone <script>', created_at:1000, active:false, current:false},
|
||||
]}},
|
||||
{status:200,payload:{revoked:true,current_session:false,session_revoked:false}},
|
||||
];
|
||||
const passkeys = await boundary.listPasskeys();
|
||||
const outcome = await boundary.revokePasskey(passkeys[0]);
|
||||
state.passkeys = passkeys;
|
||||
state.outcome = outcome;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["passkeys"][0]["device_label"] == "Old phone <script>"
|
||||
assert result["outcome"] == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/passkeys"
|
||||
assert result["requests"][1]["url"] == (
|
||||
"/dashboard/api/v1/passkeys/passkey-id-12345678"
|
||||
)
|
||||
assert result["requests"][1]["method"] == "DELETE"
|
||||
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["confirmations"] == [
|
||||
"Remove the passkey for Old phone <script>? This cannot be undone."
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_pages_are_loaded_with_stable_cursors():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {events:[{id:7,kind:'sign_in',method:'passkey',device_label:'Phone',target:'dashboard',created_at:1000}],next_cursor:7};
|
||||
const first = await boundary.listSecurityEvents();
|
||||
const second = await boundary.listSecurityEvents(first.next_cursor);
|
||||
state.first = first;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["first"]["events"][0]["method"] == "passkey"
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/security-events?limit=25",
|
||||
"/dashboard/api/v1/security-events?limit=25&cursor=7",
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "Outcome confirmation pending" in source
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "pull_review_approved: 'Pull request approved'" in source
|
||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -617,5 +683,11 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '<button id="active-devices" type="button">Active devices</button>' in html
|
||||
assert 'id="active-devices-sheet"' in html
|
||||
assert 'aria-label="Active devices"' in html
|
||||
assert 'aria-label="Active devices and passkeys"' in html
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
@@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item));
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_held_review_as_awaiting_foreground_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc',comments:[],ownerLogin:'timmy',
|
||||
status:'authorization',error:'Fresh authorization required',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{item:inbox.list()[0],partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["status"] == "authorization"
|
||||
assert output["item"]["label"] == "Review awaiting authorization"
|
||||
assert output["item"]["authorization_required"] is True
|
||||
assert output["partition"]["counts"]["authorization"] == 1
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
|
||||
@@ -42,6 +42,64 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "dashboard.css").write_text(
|
||||
(changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.runtime_bytes == first.runtime_bytes
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in (
|
||||
changed.service_worker_source
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
[
|
||||
"manifest.webmanifest",
|
||||
"icons/stackchain-192.png",
|
||||
"icons/stackchain-512.png",
|
||||
"background-issue-sync.js",
|
||||
"service-worker.js",
|
||||
],
|
||||
)
|
||||
def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
path = changed_frontend / asset
|
||||
path.write_bytes(path.read_bytes() + b"\nrelease-change")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
|
||||
|
||||
def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v85';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest == first.shell_digest
|
||||
assert changed.service_worker_source == first.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -430,6 +430,62 @@ async def test_repository_page_reports_more_results_and_targeted_access_uses_rep
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_search_finds_visible_repositories_without_loading_pages():
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append((request.url.path, dict(request.url.params)))
|
||||
return httpx.Response(200, json={
|
||||
"ok": True,
|
||||
"data": [
|
||||
{"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True},
|
||||
"invalid",
|
||||
],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.search_repositories(" mobile ", limit=20)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [{
|
||||
"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True,
|
||||
}]
|
||||
assert requests == [(
|
||||
"/api/v1/repos/search", {"q": "mobile", "limit": "20", "page": "1"},
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_search_endpoint_returns_minimal_visible_matches(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def search(query, limit):
|
||||
calls.append((query, limit))
|
||||
return [{
|
||||
"id": 151,
|
||||
"name": "mobile",
|
||||
"full_name": "stackchain/mobile",
|
||||
"html_url": "https://forge.example/stackchain/mobile",
|
||||
"description": "secret context that the picker does not need",
|
||||
"private": True,
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "search_repositories", search)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/repositories/search?q=%20mobile%20&limit=20")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"items": [{
|
||||
"id": 151, "name": "mobile", "full_name": "stackchain/mobile",
|
||||
}]}
|
||||
assert calls == [("mobile", 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
access_calls = []
|
||||
|
||||
@@ -18,15 +18,19 @@ def run_node(script: str) -> str:
|
||||
).stdout
|
||||
|
||||
|
||||
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_upload():
|
||||
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_binary_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls = [];
|
||||
const file = {{name:'checkout.png', type:'image/png', size:8}};
|
||||
const file = new Blob(['png-bytes'],{{type:'image/png'}}); file.name='checkout.png';
|
||||
const controller = attachment.create({{
|
||||
createOperationId: () => 'attachment-comment-471',
|
||||
readDataUrl: async selected => {{ calls.push('read:' + selected.name); return 'data:image/png;base64,iVBORw0KGgo='; }},
|
||||
upload: async payload => {{ calls.push(payload); return {{markdown:''}}; }},
|
||||
readDataUrl: async () => {{ throw new Error('base64 conversion must not run'); }},
|
||||
upload: async payload => {{ calls.push({{
|
||||
repository:payload.repository,number:payload.number,filename:payload.filename,
|
||||
content_type:payload.content_type,isBlob:payload.blob instanceof Blob,
|
||||
text:await payload.blob.text(),operation_id:payload.operation_id
|
||||
}}); return {{markdown:''}}; }},
|
||||
}});
|
||||
controller.select(file);
|
||||
(async()=>{{
|
||||
@@ -40,19 +44,17 @@ controller.select(file);
|
||||
"Layout breaks\n\n"
|
||||
)
|
||||
assert output["second"] == output["first"]
|
||||
assert output["calls"] == [
|
||||
"read:checkout.png",
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": 17,
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
"operation_id": "attachment-comment-471",
|
||||
},
|
||||
]
|
||||
assert output["calls"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 17,
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"isBlob": True,
|
||||
"text": "png-bytes",
|
||||
"operation_id": "attachment-comment-471",
|
||||
}]
|
||||
assert output["state"] == {
|
||||
"name": "checkout.png", "size": 8, "uploaded": True
|
||||
"name": "checkout.png", "size": 9, "uploaded": True
|
||||
}
|
||||
|
||||
|
||||
@@ -82,26 +84,129 @@ controller.select({{name:'first.png',type:'image/png',size:8}});
|
||||
]
|
||||
|
||||
|
||||
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
|
||||
def test_selected_screenshot_serializes_as_binary_blob_without_base64_expansion():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls=[];
|
||||
const file=new Blob(['binary-image'],{{type:'image/webp'}});
|
||||
file.name='phone.webp';
|
||||
const controller=attachment.create({{
|
||||
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
|
||||
readDataUrl:async()=>{{throw new Error('base64 conversion must not run');}},
|
||||
upload:async()=>{{calls.push('upload');}},
|
||||
}});
|
||||
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
|
||||
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
|
||||
controller.select(file);
|
||||
controller.serialize().then(async value=>process.stdout.write(JSON.stringify({{
|
||||
filename:value.filename,contentType:value.contentType,
|
||||
isBlob:value.blob instanceof Blob,size:value.blob.size,text:await value.blob.text(),calls
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"value": {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"data": "UklGRg==",
|
||||
},
|
||||
"calls": ["read:phone.webp"],
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"isBlob": True,
|
||||
"size": 12,
|
||||
"text": "binary-image",
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_oversized_screenshot_is_optimized_before_preview_and_durable_serialization():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const original = new Blob([new Uint8Array(attachment.MAX_BYTES + 200)], {{type:'image/png'}});
|
||||
original.name = 'phone.png';
|
||||
const calls = [];
|
||||
const controller = attachment.create({{
|
||||
optimizeImage: async file => {{
|
||||
calls.push({{name:file.name,type:file.type,size:file.size}});
|
||||
const optimized = new Blob(['optimized-png'], {{type:'image/png'}});
|
||||
optimized.name = file.name;
|
||||
return optimized;
|
||||
}},
|
||||
upload: async () => {{ throw new Error('must not upload during selection'); }},
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.select(original);
|
||||
const serialized = await controller.serialize();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,state:controller.state(),name:serialized.filename,
|
||||
contentType:serialized.contentType,size:serialized.blob.size,
|
||||
text:await serialized.blob.text(),sameBlob:serialized.blob===original,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"calls": [{
|
||||
"name": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 2 * 1024 * 1024 + 200,
|
||||
}],
|
||||
"state": {"name": "phone.png", "size": 13, "uploaded": False},
|
||||
"name": "phone.png",
|
||||
"contentType": "image/png",
|
||||
"size": 13,
|
||||
"text": "optimized-png",
|
||||
"sameBlob": False,
|
||||
}
|
||||
|
||||
|
||||
def test_browser_optimizer_downscales_until_encoded_image_fits_limit():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const original = new Blob([new Uint8Array(attachment.MAX_BYTES + 1)], {{type:'image/jpeg'}});
|
||||
original.name = 'camera.jpg';
|
||||
const attempts = [];
|
||||
let closed = false;
|
||||
const canvas = {{
|
||||
width: 0, height: 0,
|
||||
getContext: () => ({{drawImage: () => {{}}}}),
|
||||
toBlob: callback => {{
|
||||
attempts.push([canvas.width, canvas.height]);
|
||||
const size = attempts.length === 1 ? attachment.MAX_BYTES + 50 : attachment.MAX_BYTES - 50;
|
||||
callback(new Blob([new Uint8Array(size)], {{type:'image/jpeg'}}));
|
||||
}},
|
||||
}};
|
||||
(async()=>{{
|
||||
const result = await attachment.optimizeImage(original, {{
|
||||
createImageBitmap: async () => ({{width:2000,height:1000,close:()=>{{closed=true;}}}}),
|
||||
createCanvas: () => canvas,
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
attempts,closed,size:result.size,type:result.type,name:result.name,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert len(output["attempts"]) == 2
|
||||
assert output["attempts"][1][0] < output["attempts"][0][0]
|
||||
assert output["attempts"][1][1] < output["attempts"][0][1]
|
||||
assert output["closed"] is True
|
||||
assert output["size"] == 2 * 1024 * 1024 - 50
|
||||
assert output["type"] == "image/jpeg"
|
||||
assert output["name"] == "camera.jpg"
|
||||
|
||||
|
||||
def test_binary_screenshot_builds_multipart_body_with_original_bytes():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
const blob=new Blob(['original-bytes'],{{type:'image/png'}});
|
||||
const form=attachment.multipart({{filename:'phone.png',contentType:'image/png',blob}});
|
||||
const file=form.get('file');
|
||||
(async()=>process.stdout.write(JSON.stringify({{
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}})))();
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "original-bytes",
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +325,88 @@ process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.
|
||||
}
|
||||
|
||||
|
||||
def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance():
|
||||
def test_oversized_attachment_view_shows_progress_then_previews_optimized_blob():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
|
||||
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
|
||||
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
|
||||
}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const original=new Blob([new Uint8Array(attachment.MAX_BYTES+10)],{{type:'image/png'}}); original.name='large.png';
|
||||
const optimized=new Blob(['small'],{{type:'image/png'}}); optimized.name='large.png';
|
||||
let finish;
|
||||
const gate=new Promise(resolve=>{{finish=resolve;}});
|
||||
const previewed=[];
|
||||
attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async()=>{{await gate;return optimized;}},
|
||||
createObjectURL:blob=>{{previewed.push(blob===optimized);return 'blob:optimized';}},
|
||||
revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[original];
|
||||
const pending=input.dispatch('change');
|
||||
const during={{message:status.textContent,disabled:input.disabled,hidden:preview.hidden}};
|
||||
finish();
|
||||
Promise.resolve(pending).then(()=>process.stdout.write(JSON.stringify({{
|
||||
during,after:{{message:status.textContent,disabled:input.disabled,hidden:preview.hidden,
|
||||
src:image.src,meta:meta.textContent}},previewed
|
||||
}}))).catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output["during"] == {
|
||||
"message": "Optimizing screenshot…", "disabled": True, "hidden": True
|
||||
}
|
||||
assert output["after"]["message"] == "Screenshot optimized and ready to upload."
|
||||
assert output["after"]["disabled"] is False
|
||||
assert output["after"]["hidden"] is False
|
||||
assert output["after"]["src"] == "blob:optimized"
|
||||
assert "large.png" in output["after"]["meta"]
|
||||
assert output["previewed"] == [True]
|
||||
|
||||
|
||||
def test_removing_screenshot_during_optimization_cancels_stale_selection():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
|
||||
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
|
||||
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
|
||||
}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const original=new Blob([new Uint8Array(attachment.MAX_BYTES+10)],{{type:'image/webp'}}); original.name='large.webp';
|
||||
const optimized=new Blob(['small'],{{type:'image/webp'}}); optimized.name='large.webp';
|
||||
let finish;
|
||||
const gate=new Promise(resolve=>{{finish=resolve;}});
|
||||
const controller=attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async()=>{{await gate;return optimized;}},
|
||||
createObjectURL:()=> 'blob:stale',revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[original];
|
||||
const pending=input.dispatch('change');
|
||||
remove.dispatch('click');
|
||||
finish();
|
||||
Promise.resolve(pending).then(async()=>process.stdout.write(JSON.stringify({{
|
||||
state:controller.state(),serialized:await controller.serialize(),disabled:input.disabled,
|
||||
hidden:preview.hidden,src:image.src,status:status.textContent,
|
||||
}}))).catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"state": None,
|
||||
"serialized": None,
|
||||
"disabled": False,
|
||||
"hidden": True,
|
||||
"src": "",
|
||||
"status": "Screenshot removed. Your comment is unchanged.",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_after_acceptance():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "issueAttachment.mount({" in source
|
||||
@@ -229,6 +415,7 @@ def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance(
|
||||
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
|
||||
assert source.count("issueAttachmentController.clear();") >= 2
|
||||
assert "'Idempotency-Key': payload.operation_id" in source
|
||||
assert "body: issueAttachment.multipart(payload)" in source
|
||||
|
||||
|
||||
def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
|
||||
@@ -249,6 +436,7 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
||||
readme = README.read_text()
|
||||
assert "PNG, JPEG, or WebP" in readme
|
||||
assert "2 MB" in readme
|
||||
assert "automatically optimizes oversized screenshots" in readme
|
||||
assert "uploads before the comment is posted" in readme
|
||||
assert "New issue" in readme
|
||||
assert "retry resumes with the confirmed issue" in readme
|
||||
|
||||
@@ -58,6 +58,32 @@ async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(mo
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_accepts_binary_multipart_without_base64_expansion(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
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/api/issues/17/attachments",
|
||||
files={"file": ("checkout.png", PNG_BYTES, "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert calls == [
|
||||
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_replays_confirmed_upload_for_same_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -234,20 +234,27 @@ const calls=[];let uploadAttempts=0;
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
|
||||
fetchJson:async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
const form=options.body instanceof FormData ? options.body : null;
|
||||
const file=form?.get('file');
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],multipart:Boolean(form),
|
||||
filename:file?.name,text:file?await file.text():null}});
|
||||
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:8}};
|
||||
}},
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'b2ZmbGluZQ=='}}}});
|
||||
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["partial"]["deliveredIssue"]["number"] == 7
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
|
||||
assert uploads[-1]["multipart"] is True
|
||||
assert uploads[-1]["filename"] == "screen.png"
|
||||
assert uploads[-1]["text"] == "offline"
|
||||
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
|
||||
assert output["result"]["confirmed"][0]["number"] == 7
|
||||
assert output["remaining"] == []
|
||||
@@ -452,14 +459,14 @@ Promise.resolve().then(async () => {{
|
||||
assert output["items"][0]["title"] == "Keep this"
|
||||
|
||||
|
||||
def test_durable_screenshot_is_mirrored_before_a_payload_free_local_index_is_committed():
|
||||
def test_durable_screenshot_is_mirrored_as_blob_before_a_payload_free_local_index_is_committed():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const events = []; const snapshots = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{
|
||||
if (value.includes('base64-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
||||
if (value.includes('binary-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
||||
events.push('local-index'); values.set(key,value);
|
||||
}},
|
||||
}};
|
||||
@@ -471,26 +478,35 @@ const outbox = createIssueOutbox({{
|
||||
}},
|
||||
}});
|
||||
(async () => {{
|
||||
const blob=new Blob(['binary-screenshot-bytes'],{{type:'image/webp'}});
|
||||
const result = await outbox.enqueueDurably({{
|
||||
repository:'stackchain/dashboard', title:'Mobile layout',
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'base64-screenshot-bytes'}},
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',blob}},
|
||||
}});
|
||||
const durable=snapshots[0][0].attachment;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
events, mirrored:snapshots[0][0], local:outbox.list()[0], result:result.item,
|
||||
raw:values.get('stackchain.issue-outbox.v1'),
|
||||
events, mirrored:{{filename:durable.filename,contentType:durable.contentType,
|
||||
isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}},
|
||||
local:outbox.list()[0], result:result.item, raw:values.get('stackchain.issue-outbox.v1'),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
||||
assert output["mirrored"]["attachment"]["data"] == "base64-screenshot-bytes"
|
||||
assert output["mirrored"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"isBlob": True,
|
||||
"size": 23,
|
||||
"text": "binary-screenshot-bytes",
|
||||
}
|
||||
assert output["local"]["attachment"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"stored": True,
|
||||
}
|
||||
assert "base64-screenshot-bytes" not in output["raw"]
|
||||
assert "binary-screenshot-bytes" not in output["raw"]
|
||||
assert output["result"]["attachment"] == output["local"]["attachment"]
|
||||
|
||||
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v82" in worker
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
@@ -9,7 +9,7 @@ CSS = ROOT / "frontend" / "dashboard.css"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
@@ -23,9 +23,15 @@ def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
assert "panel:qs('.create-issue-panel')" in dashboard
|
||||
assert "workspace:qs('#create-issue-form')" in dashboard
|
||||
assert "focusWithin:true" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
assert ".create-issue-panel.composer-keyboard-active" in css
|
||||
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
|
||||
|
||||
|
||||
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
@@ -35,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v82" in worker
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
|
||||
|
||||
|
||||
def run_controller_scenario(scenario: str) -> dict:
|
||||
def run_controller_scenario(scenario: str, *, focus_within: bool = False) -> dict:
|
||||
harness = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
class Target {{
|
||||
@@ -41,7 +41,7 @@ const status = element(); status.workspace = workspace;
|
||||
const document = {{activeElement:null}};
|
||||
const scheduled = [];
|
||||
const controller = createController({{
|
||||
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
|
||||
entries:[{{panel, workspace, composer, submit, status, focusWithin:{json.dumps(focus_within)}}}], viewport, mediaQuery, document,
|
||||
schedule: callback => scheduled.push(callback),
|
||||
}});
|
||||
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
|
||||
@@ -127,3 +127,39 @@ process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewpor
|
||||
)
|
||||
|
||||
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
|
||||
|
||||
|
||||
def test_focus_within_reveals_each_active_capture_field_without_duplicate_viewport_listeners():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
panel.scrollTop = 180;
|
||||
document.activeElement = composer;
|
||||
workspace.dispatch('focusin', {target:composer});
|
||||
document.activeElement = submit;
|
||||
workspace.dispatch('focusin', {target:submit});
|
||||
viewport.dispatch('resize');
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
composerReveals:composer.reveals.length,
|
||||
submitReveals:submit.reveals.length,
|
||||
workspaceReveals:workspace.reveals.length,
|
||||
resizeListeners:viewport.count('resize'),
|
||||
scrollListeners:viewport.count('scroll'),
|
||||
geometry:panel.style.values,
|
||||
}));
|
||||
""",
|
||||
focus_within=True,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"composerReveals": 1,
|
||||
"submitReveals": 2,
|
||||
"workspaceReveals": 0,
|
||||
"resizeListeners": 1,
|
||||
"scrollListeners": 1,
|
||||
"geometry": {
|
||||
"--composer-viewport-top": "12px",
|
||||
"--composer-viewport-height": "360px",
|
||||
},
|
||||
}
|
||||
@@ -176,19 +176,137 @@ controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}
|
||||
}
|
||||
|
||||
|
||||
def test_unread_issue_update_claims_and_starts_once_without_marking_it_read():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
let starts = 0;
|
||||
let finishClaim;
|
||||
const reconciled = [];
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => true,
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
||||
addToday: () => 'added',
|
||||
start: item => {{ starts += 1; return item.notification_id === 42 ? 'started' : 'broken'; }},
|
||||
recover: () => {{ throw new Error('recovery should not run'); }},
|
||||
onClaimed: item => reconciled.push(item),
|
||||
onStartState: state => states.push(state),
|
||||
onState: () => {{}},
|
||||
}});
|
||||
controller.open({{
|
||||
repository:'stackchain/api', title:'Retry deploy',
|
||||
issue:{{number:7, assignees:[], claimable:true}},
|
||||
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
||||
const first = controller.start();
|
||||
const second = controller.start();
|
||||
if (first !== second || claims !== 1) throw new Error('start was not single-flight');
|
||||
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
||||
(async () => {{
|
||||
const result = await first;
|
||||
process.stdout.write(JSON.stringify({{result, claims, starts, reconciled, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "started"
|
||||
assert output["claims"] == 1
|
||||
assert output["starts"] == 1
|
||||
assert len(output["reconciled"]) == 1
|
||||
assert output["reconciled"][0]["notification_id"] == 42
|
||||
assert output["reconciled"][0]["has_update"] is True
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Assigned, added to Today, and ready to work. The update is still unread.",
|
||||
}
|
||||
|
||||
|
||||
def test_unread_update_start_checks_today_capacity_before_claiming():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => false,
|
||||
claim: () => {{ claims += 1; return Promise.resolve({{number:7}}); }},
|
||||
addToday: () => 'added', start: () => 'started',
|
||||
onStartState: state => states.push(state), onState: () => {{}},
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.start();
|
||||
process.stdout.write(JSON.stringify({{result, claims, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "full"
|
||||
assert output["claims"] == 0
|
||||
assert output["states"][-1] == {
|
||||
"action": "start",
|
||||
"busy": False,
|
||||
"message": "Today is limited to 5 items. Remove one before taking ownership.",
|
||||
}
|
||||
|
||||
|
||||
def test_unread_update_opens_owned_issue_when_today_start_throws():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let recovered = null;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => true,
|
||||
claim: () => Promise.resolve({{number:7, title:'Retry deploy'}}),
|
||||
addToday: () => 'added',
|
||||
start: () => {{ throw new Error('storage failed'); }},
|
||||
recover: item => {{ recovered = item; }},
|
||||
onStartState: state => states.push(state), onState: () => {{}},
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.start();
|
||||
process.stdout.write(JSON.stringify({{result, recovered, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "recovery"
|
||||
assert output["recovered"]["number"] == 7
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-ownership.js"></script>' in html
|
||||
assert 'id="update-ownership-action"' in html
|
||||
assert 'id="update-ownership-start"' in html
|
||||
assert '>Take ownership & start</button>' in html
|
||||
assert 'hidden aria-describedby="update-sheet-status"' in html
|
||||
assert 'const updateOwnership = createUpdateOwnership({' in html
|
||||
assert 'available: () => createAndStart.available()' in html
|
||||
assert 'const outcome = createAndStart.complete(item)' in html
|
||||
assert "openRoutedWork(item, qs('#update-ownership-start'))" in html
|
||||
assert 'updateOwnership.open(detail, selectedUpdate)' in html
|
||||
assert "qs('#update-ownership-action').addEventListener('click'" in html
|
||||
assert "qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start())" in html
|
||||
assert "lastContextSnapshot.issues" in html
|
||||
assert "todayWork.add(item)" in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
assert '.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden;' in html
|
||||
|
||||
|
||||
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||
@@ -2397,6 +2515,23 @@ async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_f
|
||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_requires_explicit_searchable_repository_selection():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-repository-search" type="search" maxlength="80"' in html
|
||||
assert 'placeholder="Search accessible repositories"' in html
|
||||
assert 'id="create-issue-repository-results" role="listbox"' in html
|
||||
assert '<option value="">Choose repository</option>' in html
|
||||
assert "issueCapture.searchRepositories(query)" in html
|
||||
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
||||
assert "renderIssueRepositoryResults(state.items)" in html
|
||||
assert "selectIssueCaptureRepository(repository)" in html
|
||||
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
|
||||
assert '.create-issue-repository-result { min-height:44px;' in html
|
||||
assert '.create-issue-repository-picker { min-width:0;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -2545,6 +2680,53 @@ capture.loadRepositoryPage(2).catch(() => {{
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_repository_search_ignores_late_results_and_preserves_draft():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const pending = {{}};
|
||||
const calls = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
fetchJson: url => {{
|
||||
calls.push(url);
|
||||
return new Promise(resolve => {{ pending[url] = resolve; }});
|
||||
}},
|
||||
}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||
const first = capture.searchRepositories('mob');
|
||||
const second = capture.searchRepositories('mobile');
|
||||
pending['api/v1/repositories/search?q=mobile&limit=20']({{items:[{{full_name:'stackchain/mobile'}}]}});
|
||||
second.then(secondResult => {{
|
||||
pending['api/v1/repositories/search?q=mob&limit=20']({{items:[{{full_name:'stackchain/obsolete'}}]}});
|
||||
first.then(firstResult => process.stdout.write(JSON.stringify({{
|
||||
calls, firstResult, secondResult, draft:capture.loadDraft()
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": [
|
||||
"api/v1/repositories/search?q=mob&limit=20",
|
||||
"api/v1/repositories/search?q=mobile&limit=20",
|
||||
],
|
||||
"firstResult": {"status": "stale", "items": []},
|
||||
"secondResult": {"status": "ready", "items": [{"full_name": "stackchain/mobile"}]},
|
||||
"draft": {
|
||||
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
@@ -4618,6 +4800,9 @@ async def test_review_attention_drafts_require_revision_instead_of_resending_sta
|
||||
assert "reviewOutbox && item.status === 'attention'" in html
|
||||
assert ">Open current review</button>" in html
|
||||
assert ">Copy feedback</button>" in html
|
||||
assert "reviewOutbox && item.status === 'authorization'" in html
|
||||
assert ">Authorize & send review</button>" in html
|
||||
assert "Review submitted and queued intent cleared." in html
|
||||
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
|
||||
|
||||
|
||||
|
||||
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -116,7 +116,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
main.request_body_limit(
|
||||
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
||||
)
|
||||
== 3 * 1024 * 1024
|
||||
== 2 * 1024 * 1024 + 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("POST", "/unrelated/attachments") is None
|
||||
assert main.request_body_limit("POST", "/unrelated") is None
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def security_access(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_security_activity_lists_private_sign_in_history(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as anonymous:
|
||||
rejected = await anonymous.get("/api/v1/security-events")
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
signed_in = await client.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Timmy's phone",
|
||||
},
|
||||
)
|
||||
activity = await client.get("/api/v1/security-events", params={"limit": 25})
|
||||
|
||||
assert rejected.status_code == 401
|
||||
assert signed_in.status_code == 200
|
||||
assert activity.status_code == 200
|
||||
assert activity.headers["cache-control"] == "no-store"
|
||||
assert activity.json() == {
|
||||
"events": [
|
||||
{
|
||||
"id": 1,
|
||||
"kind": "sign_in",
|
||||
"method": "token",
|
||||
"device_label": "Timmy's phone",
|
||||
"target": "dashboard",
|
||||
"created_at": activity.json()["events"][0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
"next_cursor": None,
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"correct horse battery staple" not in persisted
|
||||
assert b"stackchain_session" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_activity_limit_is_validated(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
too_large = await client.get("/api/v1/security-events", params={"limit": 101})
|
||||
invalid_cursor = await client.get("/api/v1/security-events", params={"cursor": 0})
|
||||
|
||||
assert too_large.status_code == 422
|
||||
assert invalid_cursor.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoked_device_activity_survives_live_session_removal(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
phone_device = next(device for device in devices if device["device_label"] == "Phone")
|
||||
csrf = laptop.cookies["stackchain_csrf"]
|
||||
grant = await laptop.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_device",
|
||||
"target": phone_device["management_id"],
|
||||
},
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
revoked = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": csrf,
|
||||
"X-Step-Up-Grant": grant.json()["grant"],
|
||||
},
|
||||
)
|
||||
active = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
history = (await laptop.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert revoked.status_code == 200
|
||||
assert [device["device_label"] for device in active] == ["Laptop"]
|
||||
assert [(event["kind"], event["device_label"]) for event in history] == [
|
||||
("device_revoked", "Phone"),
|
||||
("sign_in", "Laptop"),
|
||||
("sign_in", "Phone"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remote_revocation_reservation_failure_preserves_the_device(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
target = next(device for device in devices if device["device_label"] == "Phone")
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await laptop.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_device",
|
||||
"target": target["management_id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await laptop.delete(
|
||||
f"/api/v1/sessions/{target['management_id']}",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
remaining = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
|
||||
assert response.status_code == 503
|
||||
assert {device["device_label"] for device in remaining} == {"Phone", "Laptop"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_protected_actions_record_only_bounded_targets(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def yes(*_args):
|
||||
return True
|
||||
|
||||
async def close(*_args):
|
||||
return {"number": 7, "state": "closed"}
|
||||
|
||||
async def merge(*_args):
|
||||
return {"number": 9, "merged": True, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
csrf_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
|
||||
async def grant(action, target):
|
||||
response = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": action,
|
||||
"target": target,
|
||||
},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
return response.json()["grant"]
|
||||
|
||||
issue_grant = await grant("close_issue", "stackchain/api#7")
|
||||
closed = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": issue_grant},
|
||||
)
|
||||
pull_grant = await grant("merge_pull", "stackchain/app#9")
|
||||
merged = await client.post(
|
||||
"/api/v1/repos/stackchain/app/pulls/9/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": pull_grant},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert closed.status_code == 200
|
||||
assert merged.status_code == 200
|
||||
assert [(event["kind"], event["target"]) for event in events[:2]] == [
|
||||
("pull_merged", "stackchain/app#9"),
|
||||
("issue_closed", "stackchain/api#7"),
|
||||
]
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"abc123" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def requested(*_args):
|
||||
return True
|
||||
|
||||
async def submit(*_args):
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready to ship with private review notes.",
|
||||
},
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert response.status_code == 201
|
||||
assert events[0] == {
|
||||
"id": events[0]["id"],
|
||||
"kind": "pull_review_approved",
|
||||
"method": None,
|
||||
"device_label": None,
|
||||
"target": "stackchain/api#7",
|
||||
"created_at": events[0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"abc123" not in persisted
|
||||
assert b"private review notes" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
submit_calls = []
|
||||
|
||||
async def requested(*_args):
|
||||
return True
|
||||
|
||||
async def submit(*args):
|
||||
submit_calls.append(args)
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready.",
|
||||
},
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Security activity is temporarily unavailable"
|
||||
assert submit_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
close_calls = []
|
||||
|
||||
async def yes(*_args):
|
||||
return True
|
||||
|
||||
async def close(*args):
|
||||
close_calls.append(args)
|
||||
return {"number": 7, "state": "closed"}
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert close_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_finalization_failure_still_reports_the_closed_issue(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def yes(*_args):
|
||||
return True
|
||||
|
||||
async def close(*_args):
|
||||
return {"number": 7, "state": "closed"}
|
||||
|
||||
class FinalizationUnavailable:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
return "durable-operation"
|
||||
|
||||
def finalize(self, _operation_id):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main, "_security_event_store", lambda: FinalizationUnavailable()
|
||||
)
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "state": "closed"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
signed_out = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
assert signed_out.status_code == 200
|
||||
assert [(event.kind, event.device_label) for event in events[:2]] == [
|
||||
("sign_out", None),
|
||||
("sign_in", "Phone"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_reservation_failure_preserves_the_active_session(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
still_authenticated = await client.get("/api/v1/sessions")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert still_authenticated.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_finalization_failure_reports_success_and_clears_cookies(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
|
||||
class FinalizationUnavailable:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
return "durable-operation"
|
||||
|
||||
def finalize(self, _operation_id):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
main, "_security_event_store", lambda: FinalizationUnavailable()
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
rejected = await client.get("/api/v1/sessions")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is False
|
||||
assert "stackchain_session=" in response.headers["set-cookie"]
|
||||
assert rejected.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
csrf_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_all_sessions",
|
||||
"target": "all",
|
||||
},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
assert response.status_code == 200
|
||||
assert events[0].kind == "all_sessions_revoked"
|
||||
assert events[0].target == "all_devices"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_reservation_failure_preserves_the_active_session(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_all_sessions",
|
||||
"target": "all",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
still_authenticated = await client.get("/api/v1/sessions")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert still_authenticated.status_code == 200
|
||||
@@ -0,0 +1,86 @@
|
||||
import sqlite3
|
||||
|
||||
from src.security_event_store import SecurityEventStore
|
||||
|
||||
|
||||
def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path):
|
||||
now = [1_000]
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: now[0])
|
||||
|
||||
store.record(
|
||||
"sign_in",
|
||||
method="token",
|
||||
device_label=" Timmy Phone " + "x" * 80,
|
||||
target="dashboard",
|
||||
)
|
||||
now[0] += 1
|
||||
store.record("device_revoked", device_label="Old phone", target="device")
|
||||
|
||||
page = store.list(limit=1)
|
||||
assert [(event.kind, event.device_label, event.target) for event in page.events] == [
|
||||
("device_revoked", "Old phone", "device")
|
||||
]
|
||||
assert page.next_cursor is not None
|
||||
|
||||
older = store.list(limit=10, cursor=page.next_cursor)
|
||||
assert older.events[0].kind == "sign_in"
|
||||
assert older.events[0].method == "token"
|
||||
assert older.events[0].device_label == ("Timmy Phone " + "x" * 52)
|
||||
|
||||
columns = {
|
||||
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
assert columns == {
|
||||
"id", "kind", "method", "device_label", "target", "created_at",
|
||||
"status", "operation_id",
|
||||
}
|
||||
|
||||
|
||||
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||
now = [0]
|
||||
store = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_events=3,
|
||||
retention_seconds=10,
|
||||
)
|
||||
for index in range(4):
|
||||
now[0] = index
|
||||
store.record("sign_in", method="token", device_label=f"Device {index}")
|
||||
|
||||
assert [event.device_label for event in store.list(limit=10).events] == [
|
||||
"Device 3", "Device 2", "Device 1"
|
||||
]
|
||||
|
||||
now[0] = 20
|
||||
store.record("sign_out", device_label="Current")
|
||||
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
||||
|
||||
|
||||
def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
|
||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||
|
||||
pending = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3", clock=lambda: 1_001
|
||||
).list(limit=10).events
|
||||
assert [(event.kind, event.target, event.status) for event in pending] == [
|
||||
("issue_closed", "stackchain/api#7", "pending")
|
||||
]
|
||||
|
||||
store.finalize(operation_id)
|
||||
|
||||
completed = store.list(limit=10).events
|
||||
assert [(event.kind, event.target, event.status) for event in completed] == [
|
||||
("issue_closed", "stackchain/api#7", "completed")
|
||||
]
|
||||
|
||||
|
||||
def test_failed_operation_can_discard_its_pending_reservation(tmp_path):
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||
|
||||
store.discard(operation_id)
|
||||
|
||||
assert store.list(limit=10).events == []
|
||||
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
state.flushResult = {login:'timmy', receipts:[
|
||||
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
|
||||
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
|
||||
{id:'review-1',status:'authorization',kind:'message',route:'#/my-work/drafts'},
|
||||
]};
|
||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
@@ -353,6 +354,14 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"title": "Queued review needs authorization",
|
||||
"options": {
|
||||
"body": "Tap to authorize it in the Delivery center.",
|
||||
"tag": "stackchain-delivery-review-1",
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
||||
@@ -385,7 +394,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -426,6 +435,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/update-reply-read-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v82';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v85';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
DRAFTS = ROOT / "frontend" / "drafts.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_every_unread_update_offers_one_reply_read_next_mobile_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "workSession.checkpointed(selectedUpdate)" not in html
|
||||
assert '<script src="static/update-reply-read-next.js"></script>' in html
|
||||
assert ".update-reply-actions button { min-height:44px; width:100%; }" in html
|
||||
|
||||
|
||||
def test_reply_read_next_orders_online_delivery_and_is_single_flight():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[]; let releaseReply;
|
||||
const controller=createController({{
|
||||
post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}},
|
||||
markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}},
|
||||
queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false,
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}},
|
||||
}});
|
||||
const item={{notification_id:91}};
|
||||
const first=controller.submit(item,'Done','op-91');
|
||||
const second=controller.submit(item,'Done','op-91');
|
||||
releaseReply({{id:7}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"same": True,
|
||||
"calls": [
|
||||
["reply", 91, "Done", "op-91"],
|
||||
["read", 91],
|
||||
["accept", 91],
|
||||
["next", 91],
|
||||
],
|
||||
"results": [
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_waits_until_the_next_update_is_open():
|
||||
script = f"""
|
||||
const createController=require({json.dumps(str(CONTROLLER))});
|
||||
let openNext;
|
||||
const controller=createController({{
|
||||
post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false,
|
||||
next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})),
|
||||
}});
|
||||
let settled=false;
|
||||
const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}});
|
||||
setTimeout(()=>{{
|
||||
const before=settled;
|
||||
openNext();
|
||||
result.then(value=>process.stdout.write(JSON.stringify({{before,value}})));
|
||||
}},0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"before": False,
|
||||
"value": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"next": {"notification_id": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[];
|
||||
const offline=Object.assign(new Error('offline'),{{status:503}});
|
||||
const controller=createController({{
|
||||
post:async()=>{{calls.push('reply');return{{id:8}};}},
|
||||
markRead:async()=>{{calls.push('read');throw offline;}},
|
||||
canQueue:error=>error.status===503,
|
||||
queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}},
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return true;}},
|
||||
}});
|
||||
controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=>
|
||||
process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
"reply",
|
||||
"read",
|
||||
["queue", {
|
||||
"kind": "update-reply-read",
|
||||
"notificationId": 22,
|
||||
"body": "Ship it",
|
||||
"operationId": "stable-op",
|
||||
"replyConfirmed": True,
|
||||
}],
|
||||
["accept", 22],
|
||||
["next", 22],
|
||||
]
|
||||
assert output["result"] == {"accepted": True, "delivery": "queued", "next": True}
|
||||
|
||||
|
||||
def test_background_retry_resumes_at_read_after_reply_checkpoint():
|
||||
item = {
|
||||
"id": "op-31", "operationId": "op-31", "ownerLogin": "timmy",
|
||||
"status": "queued", "kind": "update-reply-read", "notificationId": 31,
|
||||
"body": "Acknowledged", "replyConfirmed": True,
|
||||
}
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let queued={json.dumps(item)};const calls=[];
|
||||
const store={{
|
||||
claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null,
|
||||
complete:async()=>calls.push('complete'),release:async()=>calls.push('release'),
|
||||
fail:async()=>calls.push('fail'),countBlocked:async()=>0,
|
||||
checkpoint:async()=>{{throw new Error('must not checkpoint again');}},
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}};
|
||||
createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
["api/v1/background-identity", None],
|
||||
["api/v1/notifications/31/read", "PATCH"],
|
||||
"complete",
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"ok": True}]
|
||||
|
||||
|
||||
def test_partial_reply_read_delivery_is_recoverable_from_update_drafts():
|
||||
record = {"version": 2, "items": [{
|
||||
"id": "op-44", "operationId": "op-44", "ownerLogin": "timmy",
|
||||
"status": "attention", "kind": "update-reply-read", "notificationId": 44,
|
||||
"body": "Handled", "replyConfirmed": True, "error": "Read failed",
|
||||
}]}
|
||||
script = f"""
|
||||
const createDraftInbox=require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outbox_kind"] == "update-reply-read"
|
||||
assert output["title"] == "Update #44"
|
||||
assert output["route"] == {"kind": "update", "notification_id": 44}
|
||||
assert output["status"] == "attention"
|
||||
Reference in new issue
Block a user