Compare commits

...
Author SHA1 Message Date
timmy f18c59c7b3 feat: content-address complete PWA shell (Closes #511)
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:25:05 +00:00
timmy e9cab92aff Merge pull request 'Make consequential pull reviews passkey-native and auditable' (#510) from timmy/509-passkey-review-activity into main
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-10 19:59:06 +00:00
timmy 877dc475dd feat: make consequential reviews passkey-native (Closes #509)
CI / lint (pull_request) Successful in 1m0s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:56:12 +00:00
timmy 28e70f6415 Merge pull request 'Require fresh authorization for consequential pull reviews' (#508) from timmy/507-fresh-review-authorization into main
CI / lint (push) Successful in 1m1s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 19:04:01 +00:00
timmy 2a6f2ab12d feat: authorize consequential pull reviews (Closes #507)
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:02:16 +00:00
timmy c8809a0728 Merge pull request 'Take ownership and start work from an unread update' (#506) from timmy/505-take-ownership-start into main
CI / lint (push) Successful in 1m7s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 18:31:05 +00:00
timmy 2717f41957 feat: start work from unread updates (Closes #505)
CI / lint (pull_request) Successful in 1m9s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:29:03 +00:00
timmy b26b57d616 Merge pull request 'Reply, mark read, and continue unread updates' (#504) from timmy/503-reply-read-next into main
CI / lint (push) Successful in 1m8s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 18:04:35 +00:00
timmy 1052ce2c84 feat: reply, mark read, and continue updates (Closes #503)
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:02:34 +00:00
timmy 4f0e6544b3 Merge pull request 'Make access revocation atomic across passkeys and sessions' (#502) from timmy/501-atomic-access-revocation into main
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 17:27:01 +00:00
timmy 91c59a2953 fix: make access revocation atomic (Closes #501)
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:24:54 +00:00
timmy 6d1fa3c068 Merge pull request 'Manage and selectively revoke enrolled passkeys' (#500) from timmy/499-manage-enrolled-passkeys into main
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 17:08:35 +00:00
timmy 21a386b94f feat: manage enrolled passkeys (Closes #499)
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:06:36 +00:00
timmy 7e5b966f77 Merge pull request 'Make destructive actions truthful when security journaling fails' (#498) from timmy/497-truthful-security-journal into main
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Merge pull request #498: truthful security-journaled actions
2026-08-10 16:32:39 +00:00
timmy 78d044f1af feat: make security-journaled actions outcome truthful (Closes #497)
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 16:30:31 +00:00
timmy 814c5726ec Merge pull request 'Optimize oversized screenshots before mobile capture' (#496) from timmy/495-optimize-oversized-screenshots into main
CI / lint (push) Successful in 1m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 15:58:35 +00:00
timmy 95a14c6de5 feat: optimize oversized mobile screenshots (Closes #495)
CI / lint (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:56:35 +00:00
timmy 37aa2dd090 Merge pull request 'Add a durable security activity center' (#494) from timmy/493-security-activity-center into main
CI / lint (push) Successful in 1m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 15:39:50 +00:00
timmy 6565165295 feat: add durable security activity center (Closes #493)
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:37:37 +00:00
timmy 4749d15d85 Merge pull request 'Use binary screenshot storage and upload end to end' (#492) from timmy/491-binary-screenshot-transport into main
CI / lint (push) Successful in 1m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 15:06:07 +00:00
timmy 16604d697b feat: use binary screenshot transport (Closes #491)
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:03:45 +00:00
timmy 59b4d5e6bf Merge pull request 'Keep mobile issue capture visible above the keyboard' (#490) from timmy/489-keyboard-safe-mobile-capture into main
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-10 14:28:57 +00:00
timmy 83af187c73 feat: keep mobile issue capture above keyboard (Closes #489)
CI / lint (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:26:56 +00:00
timmy d4896e10be Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488) from timmy/487-searchable-repository-picker into main
CI / lint (push) Successful in 1m1s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488)
2026-08-10 14:03:40 +00:00
timmy 81e1af7f16 feat: add explicit searchable repository picker (Closes #487)
CI / lint (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:01:49 +00:00
timmy b1b3f758e4 Merge pull request 'Add device passkeys for operator access' (#486)
CI / lint (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-10 13:16:21 +00:00
48 changed files with 3861 additions and 308 deletions

No files matched your search

+38 -8
View File
@@ -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
+27 -3
View File
@@ -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);
+71 -21
View File
@@ -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() {
+22 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 &amp; 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
View File
@@ -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
View File
@@ -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 &amp; next</button>
<button id="send-update-reply-read-next" type="button">Reply, mark read &amp; 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 &amp; start</button>
</div>
<button class="share-work-route" type="button">Share</button>
<button id="mark-update-read-next" type="button">Mark read &amp; 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
View File
@@ -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 };
});
+20 -9
View File
@@ -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 || '');
+7 -3
View File
@@ -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
View File
@@ -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;
+6 -3
View File
@@ -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 },
});
+145
View File
@@ -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,
+92 -12
View File
@@ -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;
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
)
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", []):
+219
View File
@@ -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,
)
+97
View File
@@ -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,)
+38 -5
View File
@@ -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 -8
View File
@@ -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:'![phone](url)'}};}}
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:'![phone.png](https://forge.example/phone.png)'}};
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():
+7 -9
View File
@@ -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 &amp; 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 &amp; 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
+398 -8
View File
@@ -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
+73 -1
View File
@@ -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
+21
View File
@@ -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))});
+58
View File
@@ -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)
+56
View File
@@ -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 = []
+215 -27
View File
@@ -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:'![checkout.png](https://forge.example/a.png)'}}; }},
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:'![checkout.png](https://forge.example/a.png)'}}; }},
}});
controller.select(file);
(async()=>{{
@@ -40,19 +44,17 @@ controller.select(file);
"Layout breaks\n\n![checkout.png](https://forge.example/a.png)"
)
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
+26
View File
@@ -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 = []
+25 -9
View File
@@ -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:'![screen.png](<https://forge/a.png>)'}};
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"]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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():
+38 -2
View File
@@ -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",
},
}
+185
View File
@@ -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 &amp; 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 &amp; send review</button>" in html
assert "Review submitted and queued intent cleared." in html
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+607
View File
@@ -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
+86
View File
@@ -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 == []
+20 -10
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+176
View File
@@ -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 &amp; 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"