Compare commits

...
Author SHA1 Message Date
timmy 163bfad30a feat: select mobile updates to mark read (Closes #545)
CI / lint (pull_request) Successful in 1m22s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 06:00:48 +00:00
timmy 23c8e57a8e Merge pull request 'Surface failed token sign-in alerts in Security activity' (#544)
CI / lint (push) Successful in 1m7s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 05:37:53 +00:00
timmy 350bfc067d feat: surface failed token sign-in alerts (Closes #543)
CI / lint (pull_request) Successful in 1m10s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 05:35:35 +00:00
timmy 98aecd8255 Merge pull request 'Lazy-load the complete pull request workspace' (#542) from timmy/541-lazy-pull-workflow into main
CI / lint (push) Successful in 1m7s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 04:55:19 +00:00
timmy 7d4ff1f095 perf: lazy-load pull request workspace (Closes #541)
CI / lint (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:53:18 +00:00
timmy 3605c42651 Merge pull request 'Acknowledge unread updates with a reaction and open the next' (#540) from timmy/539-acknowledge-update-reaction into main
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-11 04:12:18 +00:00
timmy d7e2813700 feat: acknowledge unread updates with reactions (Closes #539)
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:10:18 +00:00
timmy fb34b5ffda Merge pull request 'Lazy-load the mobile issue capture workflow' (#538) from timmy/537-lazy-issue-capture into main
CI / lint (push) Successful in 1m8s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 03:32:15 +00:00
timmy 820da7130d feat: lazy-load issue capture workflow (Closes #537)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:30:07 +00:00
timmy 3bad8a0198 Merge pull request 'Keep passkey sign-in available during anonymous challenge floods' (#536) from timmy/535-passkey-flood-protection into main
CI / lint (push) Successful in 1m11s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 03:04:42 +00:00
timmy 8bc5f623ac feat: protect passkey sign-in from challenge floods (Closes #535)
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-11 03:02:42 +00:00
timmy 175817e6d8 Merge pull request 'Manage Today blockers offline and sync on reconnect' (#534) from timmy/533-offline-blocker-sync into main
CI / lint (push) Successful in 1m9s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 02:32:31 +00:00
timmy fff7bccf08 feat: sync Today blockers offline (Closes #533)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:29:56 +00:00
timmy ee36b8824a Merge pull request 'Manage issue blockers without leaving Today' (#532) from timmy/531-manage-issue-blockers into main
CI / lint (push) Successful in 1m15s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-11 02:04:33 +00:00
timmy 2c8a1225e0 feat: manage issue blockers in app (Closes #531)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:02:23 +00:00
timmy 29ceedfc01 Merge pull request 'Keep dashboard live when IndexedDB stalls' (#530) from timmy/529-indexeddb-startup-deadline into main
CI / lint (push) Successful in 1m13s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 01:31:05 +00:00
timmy 11a56b21a1 fix: bound IndexedDB dashboard startup (Closes #529)
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:28:56 +00:00
timmy 3e16dd00d1 Merge pull request 'Attach screenshots to unread update replies' (#528) from timmy/527-update-reply-screenshots into main
CI / lint (push) Successful in 1m11s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 01:10:54 +00:00
timmy 31f6097afc feat: attach screenshots to update replies (Closes #527)
CI / lint (pull_request) Successful in 1m13s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:04:18 +00:00
timmy beaf5e0587 Merge pull request 'Move offline work details from localStorage to IndexedDB' (#526) from timmy/525-indexeddb-offline-work into main
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-11 00:36:26 +00:00
timmy 0a96dfb173 feat: move offline work into IndexedDB (Closes #525)
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-11 00:34:25 +00:00
timmy 7cadb36b41 Merge pull request 'Lock cached dashboard after the confirmed idle deadline' (#524) from timmy/523-confirmed-offline-idle-deadline into main
CI / lint (push) Successful in 1m12s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 00:02:39 +00:00
timmy b1890a29ff feat: enforce confirmed offline idle deadlines (Closes #523)
CI / lint (pull_request) Successful in 1m15s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:00:19 +00:00
timmy 100d38cc5c Merge pull request 'Refresh pull request checks without reloading review data' (#522) from timmy/521-status-only-check-refresh into main
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 23:28:26 +00:00
timmy e5a352b418 perf: isolate pull check refreshes (Closes #521)
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 23:26:35 +00:00
timmy 8907263760 Merge pull request 'Avoid decoding unchanged live snapshots on every poll' (#520) from timmy/519-metadata-only-live-polls 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 22:56:35 +00:00
timmy fdcd0d31ab perf: reuse unchanged live snapshot values (Closes #519)
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:54:08 +00:00
timmy 5bef6291e8 Merge pull request 'Show actionable CI checks in mobile pull reviews' (#518) from timmy/517-actionable-ci-checks into main
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
2026-08-10 22:33:52 +00:00
timmy 9724358c89 feat: expose actionable pull request checks (Closes #517)
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 22:31:50 +00:00
timmy 7555cefccb Merge pull request 'Attach screenshots to assigned pull request comments online and offline' (#516) from timmy/515-pr-comment-screenshots into main
CI / lint (push) Successful in 1m10s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 21:44:58 +00:00
timmy 04621223fd feat: attach screenshots to pull comments (Closes #515)
CI / lint (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 21:39:22 +00:00
timmy e5016d8b86 Merge pull request 'Audit passkey enrollment with durable Security activity' (#514) from timmy/513-audit-passkey-enrollment into main
CI / lint (push) Successful in 59s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 20:58:07 +00:00
timmy b455dc6286 feat: audit passkey enrollment (Closes #513)
CI / lint (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:56:20 +00:00
timmy 338b6ebbd0 Make every PWA shell release content-addressed and atomic
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #511
2026-08-10 20:27:06 +00:00
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
76 changed files with 8923 additions and 512 deletions

No files matched your search

+63 -20
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.
For online delivery, the screenshot uploads before the comment is posted; validation or upload
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each 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—to the exact assigned issue or pull request—and produces one Markdown comment; 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'
@@ -166,6 +178,11 @@ export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Public passkey ceremonies share the same window and durable source ledger.
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
# Bound live one-time challenges even if anonymous clients rotate addresses.
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Trust forwarding headers only from these immediate reverse-proxy networks.
@@ -173,13 +190,16 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Sign-in failures are scoped to a hashed canonical client address and persisted across
workers and restarts. Once the budget is exhausted, the server returns `429` with
`Retry-After`; the mobile login form disables retries for that interval. Expired
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
that setting, a reverse proxy is safely treated as one shared source.
Token and passkey sign-in failures are scoped to a hashed canonical client address and
persisted across workers and restarts. Public passkey option issuance has a separate
fixed-window admission budget in the same ledger, and live challenges are bounded per
source and globally in the session registry. Once either sign-in budget is exhausted,
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
failure state. Expired source records and challenges are pruned, and both ledgers are
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
networks you operate. Without that setting, a reverse proxy is safely treated as one
shared source.
Inbound API mutations are admitted through a body-size boundary before FastAPI
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
@@ -199,14 +219,33 @@ 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, passkey enrollments, 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, public keys, challenges, attestation data, raw network addresses,
request bodies, or comment content. Keep its database on the same class of persistent,
writable storage as the session registry. Before passkey credential creation, session
revocation, or issue closure, the journal durably reserves a pending event; if that
reservation fails, the consequential 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
@@ -355,8 +394,12 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
per file. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages.
This account-bound cache is limited to ten records across all detail kinds; credentials,
repository catalogs, events, raw patches, and complete API responses are excluded.
The snapshot and each account-bound detail are committed as separate IndexedDB records,
so large review previews do not consume the small synchronous localStorage quota or require
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
patches, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
@@ -370,8 +413,8 @@ queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically.
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
IndexedDB records; seven-day expiry removes stale records automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
+61 -4
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', 'issue-blocker']);
function reviewFingerprint(message) {
return JSON.stringify({
@@ -74,7 +74,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'issue-comment' && message.attachment ? {
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
@@ -90,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
...(message.kind === 'issue-blocker' ? {
blockerRepository: String(message.blockerRepository || ''),
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
};
items.push(item);
write(items, mirror);
@@ -98,6 +104,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
@@ -106,7 +117,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 {
@@ -156,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
@@ -182,12 +197,49 @@ 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 },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else if (item.kind === 'issue-blocker') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present,
}),
});
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
throw new Error('Blocker change was not confirmed.');
}
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
@@ -296,6 +348,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);
+154 -23
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 {
@@ -351,6 +368,24 @@ function createBackgroundIssueSync({
},
};
}
if (item.kind === 'issue-blocker') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present === true,
}),
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@@ -417,6 +452,57 @@ function createBackgroundIssueSync({
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
let attachmentMarkdown = current.attachmentMarkdown;
if (current.attachment && !attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
}
const text = String(current.body || '').trim();
const replyBody = attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
const options = authoredRequest('', { ...current, body:replyBody }).options;
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
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 +519,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 || '');
@@ -468,22 +550,19 @@ function createBackgroundIssueSync({
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/attachments',
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
{
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 || '');
@@ -497,7 +576,7 @@ function createBackgroundIssueSync({
const text = String(item.body || '').trim();
return requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/comments',
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
{
method: 'POST',
headers: {
@@ -509,10 +588,41 @@ function createBackgroundIssueSync({
);
}
async function deliverUpdateScreenshotReply(item) {
let current = item;
let attachmentMarkdown = current.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
});
}
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.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
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') {
@@ -520,6 +630,16 @@ function createBackgroundIssueSync({
error.status = 422;
throw error;
}
if (item.kind === 'issue-blocker') {
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
const error = new Error('Blocker change was not confirmed.');
error.status = 422;
throw error;
}
}
await completeClaim(item, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
@@ -529,6 +649,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 +688,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 +755,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,
};
+62 -2
View File
@@ -16,11 +16,24 @@ 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; }
.authentication-alert { border-color:#d69e2e; background:#241b09; }
.authentication-alert strong { color:#f6c453; }
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
#load-more-security-activity { width:100%; min-height:44px; }
button:hover { filter: brightness(1.15); }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
@@ -140,6 +153,12 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.update-selection-controls button { min-height:44px; }
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
.my-work-card.selection-active { border-color:#31577f; }
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
@@ -221,6 +240,18 @@ textarea { resize: vertical; min-height: 120px; }
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
.review-action { min-height:44px; }
.review-retry { min-height:44px; margin-top:10px; }
.ci-checks { max-width:100%; margin:10px 0; border:1px solid #2a496e; border-radius:10px; overflow:hidden; }
.ci-checks > summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 10px; cursor:pointer; }
.ci-checks > summary .small { min-width:0; overflow-wrap:anywhere; text-align:right; }
.ci-check-refresh { width:100%; min-height:44px; margin-bottom:6px; }
.ci-check-list { display:grid; gap:6px; min-width:0; padding:0 8px 8px; }
.ci-check { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:8px; border:1px solid #203a5c; border-radius:8px; }
.ci-check-failure, .ci-check-error { border-color:#dc2626; background:rgba(127,29,29,.22); }
.ci-check-pending { border-color:#d97706; background:rgba(120,53,15,.2); }
.ci-check-success { border-color:#15803d; }
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
@@ -232,8 +263,17 @@ textarea { resize: vertical; min-height: 120px; }
.update-reply button { min-height:44px; width:100%; }
.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-reply .issue-attachment-preview { width:100%; min-width:0; }
@media (max-width:390px) {
.update-reply-actions { grid-template-columns:1fr; }
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
}
.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 #acknowledge-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
@@ -247,6 +287,14 @@ textarea { resize: vertical; min-height: 120px; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
.issue-blocker-row .issue-blocker { min-width:0; }
.issue-blocker-remove { min-height:44px; white-space:normal; }
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
@@ -349,6 +397,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; }
@@ -416,6 +470,7 @@ textarea { resize: vertical; min-height: 120px; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
.card-planning > summary::-webkit-details-marker { display:none; }
@@ -429,8 +484,10 @@ 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; }
.issue-blocker-row { grid-template-columns:1fr; }
.issue-blocker-remove { width:100%; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; }
@@ -440,9 +497,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); }
+657 -131
View File
File diff suppressed because it is too large. Load diff
+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,
+62
View File
@@ -0,0 +1,62 @@
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
const pending = new Map();
const loaded = new Set();
function load(name) {
if (loaded.has(name)) return Promise.resolve();
if (pending.has(name)) return pending.get(name);
const url = urls[name];
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
const request = new Promise((resolve, reject) => {
const script = document.createElement('script');
let timeout;
const fail = () => {
clearTimeout(timeout);
script.remove();
pending.delete(name);
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
};
script.src = url;
script.async = true;
script.onload = () => {
clearTimeout(timeout);
pending.delete(name);
loaded.add(name);
resolve();
};
script.onerror = fail;
timeout = setTimeout(fail, timeoutMs);
document.head.appendChild(script);
});
pending.set(name, request);
return request;
}
async function run(name, elements, callback) {
const trigger = elements?.trigger;
const status = elements?.status;
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
if (trigger) trigger.disabled = true;
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
try {
await load(name);
callback();
if (status) status.textContent = '';
return true;
} catch (_error) {
if (status) {
const label = name === 'issue-capture' ? 'Issue capture' :
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
status.textContent = label + ' could not load. ' + retryLabel;
}
return false;
} finally {
if (trigger) trigger.disabled = false;
}
}
return { load, run, ready: name => loaded.has(name) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
+81 -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>
@@ -80,6 +98,7 @@
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
@@ -97,6 +116,11 @@
</div>
</details>
</div>
<div class="update-selection-controls" id="update-selection-controls" hidden>
<button id="select-updates" type="button">Select updates</button>
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
@@ -288,6 +312,16 @@
<h2 id="issue-blockers-title">Blocked by</h2>
<div class="issue-blocker-list" id="issue-blocker-list"></div>
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
<label for="issue-blocker-search">Search open issues</label>
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
<button id="cancel-issue-blocker" type="button">Cancel</button>
</div>
</section>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
@@ -408,9 +442,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
@@ -492,15 +532,28 @@
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="update-reply-attachment-meta"></span>
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
</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="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</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>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
@@ -531,6 +584,15 @@
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
<img id="pull-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="pull-attachment-meta"></span>
<button id="remove-pull-attachment" type="button">Remove screenshot</button>
</div>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
@@ -542,6 +604,11 @@
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<details class="ci-checks" id="pull-checks">
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="pull-check-list"></div>
</details>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
<button id="merge-pull" type="button" disabled>Merge</button>
@@ -573,6 +640,11 @@
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<details class="ci-checks" id="review-checks">
<summary><span>Checks</span><span class="small" id="review-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-review-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="review-check-list"></div>
</details>
<h2>Changed files</h2>
<div class="review-display-tools">
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
@@ -652,6 +724,7 @@
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
@@ -663,6 +736,7 @@
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/offline-issue-blocker.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
@@ -672,6 +746,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>
+130 -30
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,61 @@
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
let selectionSequence = 0;
function setBusy(busy) {
options.input.disabled = Boolean(busy);
options.remove.disabled = Boolean(busy);
}
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 +243,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;
@@ -152,8 +252,8 @@
return restored;
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
}
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 || '');
+19
View File
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = null;
let blockerRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
updateBlocker(item, blocker, remove = false) {
if (blockerRequest) return blockerRequest;
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
method: remove ? 'DELETE' : 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
}).then(result => {
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate =>
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
);
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
throw new Error('Blocker change was not confirmed.');
}
return result;
}).finally(() => { blockerRequest = null; });
return blockerRequest;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
+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);
}
+112 -14
View File
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
let active = false;
const selected = new Set();
const snapshot = () => ({
active,
ids: Array.from(selected),
count: selected.size,
limit: maximum,
at_limit: selected.size >= maximum,
});
const publish = () => onChange(snapshot());
const select = notificationId => {
if (!active || !Number.isInteger(notificationId)) return 'inactive';
if (selected.has(notificationId)) return 'already-selected';
if (selected.size >= maximum) return 'limit';
selected.add(notificationId);
publish();
return 'selected';
};
return {
start() {
if (active) return snapshot();
active = true;
publish();
return snapshot();
},
select,
toggle(notificationId) {
if (!selected.has(notificationId)) return select(notificationId);
selected.delete(notificationId);
publish();
return 'deselected';
},
retain(notificationIds) {
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
let changed = false;
selected.forEach(id => {
if (!allowed.has(id)) {
selected.delete(id);
changed = true;
}
});
if (changed) publish();
return snapshot();
},
cancel() {
active = false;
selected.clear();
publish();
return snapshot();
},
snapshot,
};
}
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
@@ -280,6 +336,7 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
acknowledge = null,
queueRead = null,
loadSaved = () => null,
loadConversation = null,
@@ -292,6 +349,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 +400,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 +431,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;
@@ -374,6 +439,21 @@ function createNotificationReader({
marking = false;
}
},
async acknowledgeAndNext(items) {
if (!selected || marking || offlineHydrated || !acknowledge) return false;
const current = selected;
marking = true;
onStatus('Adding reaction and marking read…');
try {
await acknowledge(current.notification_id);
return await advanceAfterRead(items, current);
} catch (_error) {
onStatus('Could not acknowledge update. Retry.');
return false;
} finally {
marking = false;
}
},
};
}
@@ -396,7 +476,7 @@ function createNotificationReplier({
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
async submit(item, body, attachment = null) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
@@ -407,6 +487,23 @@ function createNotificationReplier({
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
if (attachment) {
onStatus('Saving screenshot for durable delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
});
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
if (!delivery.confirmed?.length) {
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') return false;
onStatus('Queued for sync when the connection returns.');
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
@@ -741,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationSelection = createNotificationSelection;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
+21
View File
@@ -0,0 +1,21 @@
function createOfflineIssueBlocker({
enqueueDurably,
onQueued = () => {},
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
}) {
return async function queueOfflineIssueBlocker(item, blocker, present) {
const admission = await enqueueDurably({
kind: 'issue-blocker',
repository: String(item.repository || ''),
number: Number(item.number || 0),
blockerRepository: String(blocker.repository || ''),
blockerNumber: Number(blocker.number || 0),
present: present === true,
operationId: String(createOperationId()).slice(0, 128),
});
onQueued(item, blocker, present === true, admission);
return { admission };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
+9 -4
View File
@@ -31,14 +31,18 @@
}
const previousFailed = failed;
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
const savedDetails = new Map();
await Promise.all(items.map(async item => {
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
}));
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
});
const candidates = items.filter(item => {
const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false;
const saved = loadSavedDetail(login, item);
const saved = savedDetails.get(key);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
});
failed = new Set();
@@ -51,8 +55,9 @@
try {
const detail = await loadDetail(item);
if (runGeneration !== generation) return;
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) readyKeys.add(itemKey(item));
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved === false) throw new Error('Offline detail was not durably admitted.');
readyKeys.add(itemKey(item));
} catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item));
}
+245 -2
View File
@@ -27,6 +27,54 @@
];
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('work')) {
request.result.createObjectStore('work', { keyPath: 'id' });
}
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => { db.close(); databasePromise = undefined; };
resolve(db);
};
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
});
return databasePromise;
}
const requested = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('work', 'readwrite');
const store = transaction.objectStore('work');
let result;
let workError;
transaction.oncomplete = () => workError ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
Promise.resolve(work({
get: key => requested(store.get(key)),
getAll: () => requested(store.getAll()),
put: value => requested(store.put(value)),
delete: key => requested(store.delete(key)),
clear: () => requested(store.clear()),
})).then(value => { result = value; }).catch(error => {
workError = error;
try { transaction.abort(); } catch (_) { reject(error); }
});
});
};
}
function pick(source, fields) {
const output = {};
fields.forEach(field => {
@@ -36,14 +84,155 @@
}
function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
maxDetails = 10, initializationTimeoutMs = 1500,
}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise;
let readinessPromise;
let readinessGeneration = 0;
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
let recordCache = null;
let pendingDurableClear = false;
function hydrateLegacyCache() {
const cache = new Map();
try {
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
if (Array.isArray(details)) details.forEach(record => {
if (!record?.key || !record?.user_login || !record?.data) return;
const id = 'detail:' + record.user_login + ':' + record.key;
cache.set(id, { ...record, id });
});
} catch (_) { cache.clear(); }
recordCache = cache;
}
function migrateLegacy(generation) {
if (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null;
let details = [];
try {
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
details = Array.isArray(parsedDetails) ? parsedDetails : [];
} catch (_) {
return false;
}
if (!snapshot && !details.length) return true;
await transact(async records => {
if (snapshot && !(await records.get('snapshot'))) {
await records.put({ ...snapshot, id:'snapshot' });
}
for (const record of details.slice(-Math.max(1, maxDetails))) {
if (!record?.key || !record?.user_login || !record?.data) continue;
const id = 'detail:' + record.user_login + ':' + record.key;
if (!(await records.get(id))) await records.put({ ...record, id });
}
});
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
return true;
})().catch(() => false);
return migrationPromise;
}
async function initialize(generation) {
const migrated = await migrateLegacy(generation);
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
if (pendingDurableClear) {
await transact(async records => { await records.clear(); });
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
pendingDurableClear = false;
recordCache = new Map();
return true;
}
return transact(async records => {
const currentTime = now().getTime();
const valid = [];
for (const record of await records.getAll()) {
const savedAt = Date.parse(record?.saved_at || '');
const validShape = record?.id === 'snapshot' ?
record.version === VERSION && record.user_login && record.data :
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
if (record?.id) await records.delete(record.id);
} else valid.push(record);
}
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
recordCache = new Map(valid.map(record => [record.id, record]));
return true;
});
}
function startReady() {
const generation = ++readinessGeneration;
readinessState = { state:'initializing', reason:'' };
const operation = initialize(generation).catch(() => false);
readinessPromise = new Promise(resolve => {
const timer = setTimeout(() => {
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'deadline' };
resolve(false);
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
operation.then(available => {
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
clearTimeout(timer);
if (available) readinessState = { state:'ready', reason:'' };
else {
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'unavailable' };
}
resolve(Boolean(available));
});
});
return readinessPromise;
}
function ready() {
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
if (readinessState.state === 'degraded') return Promise.resolve(false);
return readinessPromise || startReady();
}
function status() {
return { ...readinessState };
}
function retry() {
if (!transact) return Promise.resolve(true);
migrationPromise = undefined;
readinessPromise = undefined;
return startReady();
}
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
if (transact) {
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ }
if (readinessState.state === 'degraded') {
pendingDurableClear = true;
recordCache = new Map();
return Promise.resolve(true);
}
migrationPromise = Promise.resolve(true);
return transact(async records => {
await records.clear();
recordCache = new Map();
return true;
}).catch(() => false);
}
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
@@ -104,6 +293,22 @@
saved_at: now().toISOString(),
data,
};
if (transact) {
record.id = 'detail:' + login + ':' + key;
return ready().then(available => available ? transact(async records => {
const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
);
await records.put(record);
const overflow = existing.concat(record).sort((a, b) =>
String(a.saved_at).localeCompare(String(b.saved_at))
).slice(0, -Math.max(1, maxDetails));
for (const candidate of overflow) await records.delete(candidate.id);
overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
@@ -117,6 +322,19 @@
const key = detailKey(item);
login = String(login || '').trim();
if (!login || !key) return null;
if (transact) {
const id = 'detail:' + login + ':' + key;
if (!recordCache) return ready().then(() => loadDetail(login, item));
const record = recordCache.get(id);
if (!record) return null;
const savedAt = Date.parse(record.saved_at || '');
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
recordCache.delete(id);
transact(async records => { await records.delete(id); }).catch(() => {});
return null;
}
return { ...record.data, saved_at: record.saved_at };
}
const records = readDetails();
const currentTime = now().getTime();
const valid = records.filter(record => {
@@ -142,6 +360,7 @@
function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false;
const record = {
id: 'snapshot',
version: VERSION,
user_login: String(snapshot.user.login),
saved_at: now().toISOString(),
@@ -154,12 +373,36 @@
notification_pagination: snapshot.notification_pagination || {},
},
};
if (transact) {
return ready().then(available => available ? transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
return true;
}
function load(expectedLogin) {
if (transact) {
if (!recordCache) return ready().then(() => load(expectedLogin));
const record = recordCache.get('snapshot');
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
if (record) {
recordCache.delete('snapshot');
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
}
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; }
@@ -175,7 +418,7 @@
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;
+4 -3
View File
@@ -36,11 +36,11 @@
});
}
function deletePrivateOutbox() {
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@@ -52,7 +52,8 @@
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
+15 -3
View File
@@ -5,7 +5,11 @@ function mergeEligibility(detail, reviewState) {
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
return { allowed: false, reason: 'CI must succeed before merging' };
const blockers = (detail.checks || []).filter(check =>
['failure', 'error', 'pending'].includes(check.state)
).map(check => check.name).filter(Boolean).slice(0, 3);
return { allowed: false, reason: blockers.length ?
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
if (reviewState && !reviewState.complete) {
@@ -40,6 +44,7 @@ function renderFile(file, index, reviewed, escapeHtml) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
@@ -64,9 +69,9 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha) {
loadReview(item, headSha, { refresh = false } = {}) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
@@ -78,6 +83,13 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
reviewRequests.set(key, request);
return request;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { checkRequest = null; });
return checkRequest;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
+39 -1
View File
@@ -4,6 +4,7 @@ function createReviewController({
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pendingSubmission = null;
let pendingChecks = null;
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
@@ -18,6 +19,14 @@ function createReviewController({
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
function loadChecks(item) {
if (pendingChecks) return pendingChecks;
pendingChecks = fetchJson(endpoint(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { pendingChecks = null; });
return pendingChecks;
}
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
@@ -44,7 +53,7 @@ function createReviewController({
return pendingSubmission;
}
return { load, submit };
return { load, loadChecks, submit };
}
function createWrapPreference({ storage, mobile }) {
@@ -355,6 +364,34 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
return true;
}
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
const items = (Array.isArray(checks) ? checks : []).filter(check =>
check && typeof check.name === 'string' && check.name
).map((check, index) => ({ ...check, index })).sort((left, right) =>
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
);
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
const pending = items.filter(check => check.state === 'pending').length;
const passed = items.filter(check => check.state === 'success').length;
const other = items.length - failed - pending - passed;
const parts = [
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
passed ? passed + ' passed' : '', other ? other + ' other' : '',
].filter(Boolean);
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
const html = items.map(check => {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
@@ -364,6 +401,7 @@ createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
createReviewController.renderChecks = renderChecks;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
+31 -11
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-v87';
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;
@@ -12,6 +12,7 @@ const SHELL = [
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/feature-loader.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
@@ -22,6 +23,7 @@ const SHELL = [
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/offline-issue-blocker.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
@@ -31,6 +33,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',
@@ -121,20 +124,25 @@ async function notifyIdleSession() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
async function storeOfflineLease(expiresAt, idleExpiresAt) {
if (
!Number.isInteger(expiresAt) || expiresAt <= 0
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
async function offlineLeaseState(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
const current = Math.floor(Date.now() / 1000);
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
return 'valid';
}
async function expiredOfflineResponse() {
@@ -151,8 +159,18 @@ async function expiredOfflineResponse() {
);
}
async function idleOfflineResponse() {
await notifyIdleSession();
return new Response(
'Your Stackchain session is locked. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
const state = await offlineLeaseState(cache);
if (state === 'expired') return expiredOfflineResponse();
if (state === 'idle') return idleOfflineResponse();
return cache.match(BASE);
}
@@ -204,11 +222,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 },
});
@@ -236,7 +256,7 @@ self.addEventListener('message', event => {
await flushAndNotify();
})());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
+234 -16
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,127 @@
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);
if (!append) {
page.authentication_alerts.forEach(alert => {
const row = root.document.createElement('article');
row.className = 'security-event authentication-alert';
const formatted = boundary.formatAuthenticationAlert(alert);
const title = root.document.createElement('strong');
title.textContent = formatted.title;
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
activityList.append(row);
});
}
const labels = {
sign_in: 'Signed in',
sign_out: 'Signed out',
passkey_enrolled: 'Passkey enrolled',
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 +214,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 {
@@ -118,6 +242,7 @@
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
let idleLockStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null;
@@ -137,8 +262,8 @@
}
function handleSessionIdle() {
if (expirationStarted) return;
expirationStarted = true;
if (expirationStarted || idleLockStarted) return;
idleLockStarted = true;
location.replace(base + 'login?reason=session-idle');
}
@@ -159,16 +284,32 @@
return Number.isInteger(value) && value > 0 ? value : 0;
}
function storedOfflineIdleLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
async function publishOfflineLease(expiresAt) {
function scheduleOfflineIdle(idleExpiresAt) {
const delay = Math.max(0, idleExpiresAt * 1000 - now());
setTimer(() => {
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
}, delay);
}
async function publishOfflineLease(expiresAt, idleExpiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
registration?.active?.postMessage({
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
});
}
function csrfToken() {
@@ -339,7 +480,10 @@
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
}
}
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
if (
response.status === 401 && isSameOrigin(input)
&& !expirationStarted && !idleLockStarted
) {
expirationStarted = true;
const payload = await response.clone().json().catch(() => ({}));
if (payload.code === 'session_revoked') {
@@ -360,12 +504,21 @@
const current = now();
if (
expirationStarted
|| idleLockStarted
|| activityHeartbeat
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
) return Promise.resolve(false);
lastActivityHeartbeatAt = current;
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
.then(response => response.ok)
.then(async response => {
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
const expiresAt = storedOfflineLease();
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
await publishOfflineLease(expiresAt, payload.idle_expires_at);
}
return true;
})
.catch(() => false)
.finally(() => { activityHeartbeat = null; });
return activityHeartbeat;
@@ -382,17 +535,27 @@
const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
await publishOfflineLease(payload.expires_at);
if (
!payload.authenticated
|| !Number.isInteger(payload.expires_at)
|| !Number.isInteger(payload.idle_expires_at)
) return false;
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) {
scheduleOfflineExpiry(expiresAt);
return true;
if (expiresAt * 1000 <= now()) {
if (expiresAt) await handleSessionExpiry();
return false;
}
if (expiresAt) await handleSessionExpiry();
return false;
const idleExpiresAt = storedOfflineIdleLease();
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
handleSessionIdle();
return false;
}
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
return true;
}
}
@@ -423,11 +586,11 @@
});
}
function deletePrivateOutbox() {
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@@ -440,7 +603,8 @@
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try {
await stopWorkerOutbox();
await deletePrivateOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
@@ -494,6 +658,56 @@
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();
}
function formatAuthenticationAlert(alert) {
const failed = Math.max(0, Number(alert?.failed_count) || 0);
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
+ (blocked ? ` · ${blocked} blocked` : '');
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
'en-US', { timeZone: 'UTC' },
);
return {
title,
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
};
}
async function listSecurityEvents(cursor = null) {
const query = new URLSearchParams({ limit: '25' });
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
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 : [],
authentication_alerts: Array.isArray(payload.authentication_alerts)
? payload.authentication_alerts : [],
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 +751,12 @@
signOut,
signOutAllDevices,
listActiveDevices,
listPasskeys,
listSecurityEvents,
formatAuthenticationAlert,
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;
+64
View File
@@ -0,0 +1,64 @@
function createUpdateReplyReadNext({
post, markRead, queue, canQueue, deliver, 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, attachment = null) {
if (inFlight) return inFlight;
inFlight = (async () => {
let replyConfirmed = false;
try {
if (attachment) {
const admission = await queue({
kind:'update-reply-read', notificationId:item.notification_id, body,
operationId, replyConfirmed:false, attachment,
});
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
const outcome = deliver ? await deliver(admission.item) : null;
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') {
throw new Error(remaining.error || 'Reply and screenshot need attention.');
}
accept(item);
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
}
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
+9 -1
View File
@@ -30,6 +30,7 @@ class Session:
session_id: str
csrf: str
expires_at: int
idle_expires_at: int | None = None
@dataclass(frozen=True)
@@ -174,7 +175,14 @@ def verify_session_with_reason(
return SessionVerification(None, "session_idle")
if status != "active":
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
return SessionVerification(
Session(
session_id=session.session_id,
csrf=session.csrf,
expires_at=session.expires_at,
idle_expires_at=getattr(status, "idle_expires_at", None),
)
)
def verify_session(value: str | None, now: int | None = None) -> Session | None:
+88 -16
View File
@@ -13,6 +13,21 @@ import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"issue-capture": ("static/create-issue-sheet.js",),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
}
CACHE_DECLARATION = re.compile(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
)
@dataclass(frozen=True)
class RuntimeBundle:
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
@dataclass(frozen=True)
@@ -22,7 +37,9 @@ class FrontendBuild:
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
shell_digest: str
page_sources: tuple[str, ...]
feature_bundles: dict[str, RuntimeBundle]
service_worker_source: str
@@ -37,6 +54,17 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
return minified + f';"source-sha256:{revision}";'.encode()
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
content = _bundle(frontend_dir, sources)
digest = hashlib.sha256(content).hexdigest()[:16]
return RuntimeBundle(
runtime_bytes=content,
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
runtime_digest=digest,
runtime_name=f"{prefix}-{digest}.js",
)
def build_frontend(frontend_dir: Path) -> FrontendBuild:
"""Return one internally consistent HTML, runtime, and offline-worker build."""
source_html = (frontend_dir / "index.html").read_text()
@@ -44,13 +72,21 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
if not sources:
raise ValueError("dashboard entry document has no page scripts")
runtime = _bundle(frontend_dir, sources)
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
core_sources = tuple(source for source in sources if source not in feature_source_names)
core = _runtime(frontend_dir, core_sources, "runtime")
feature_bundles = {
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
for name, feature_sources in FEATURE_SOURCES.items()
}
dashboard_html = SCRIPT_TAG.sub("", source_html)
feature_metadata = "\n".join(
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
for name, bundle in feature_bundles.items()
)
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
)
worker = (frontend_dir / "service-worker.js").read_text()
@@ -59,21 +95,57 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
worker = worker.replace(f" BASE + '{source}',\n", "")
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
)
worker = 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()),
(core.runtime_name, core.runtime_bytes),
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
(
"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(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_gzip_bytes=runtime_gzip,
runtime_digest=digest,
runtime_name=runtime_name,
runtime_bytes=core.runtime_bytes,
runtime_gzip_bytes=core.runtime_gzip_bytes,
runtime_digest=core.runtime_digest,
runtime_name=core.runtime_name,
shell_digest=shell_digest,
page_sources=sources,
feature_bundles=feature_bundles,
service_worker_source=worker,
)
)
+284 -1
View File
@@ -4,7 +4,7 @@ import re
import shlex
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import urlsplit
from urllib.parse import urljoin, urlsplit
import httpx
@@ -169,6 +169,10 @@ class IssueEditConflictError(ValueError):
"""Raised when an issue changed after the editor loaded it."""
class IssueDependencyInvalidError(ValueError):
"""Raised when a requested blocker relationship is not valid."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -301,6 +305,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(
@@ -585,6 +604,51 @@ def _safe_web_url(value: Any) -> str:
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
def _safe_gitea_web_url(value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ""
resolved = urljoin(f"{GITEA_URL}/", value.strip())
parsed = urlsplit(resolved)
configured = urlsplit(GITEA_URL)
base_path = configured.path.rstrip("/")
if (
parsed.scheme not in {"http", "https"}
or parsed.scheme != configured.scheme
or parsed.netloc != configured.netloc
or (base_path and parsed.path != base_path and not parsed.path.startswith(f"{base_path}/"))
):
return ""
return resolved
def _normalize_commit_checks(status: Any) -> list[dict]:
entries = status.get("statuses") if isinstance(status, dict) else None
if not isinstance(entries, list):
return []
rank = {"error": 0, "failure": 0, "pending": 1, "warning": 2, "success": 3}
checks = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
name = entry.get("context")
if not isinstance(name, str) or not name.strip():
continue
state = entry.get("status", entry.get("state", "unknown"))
state = state.lower() if isinstance(state, str) else "unknown"
if state not in rank:
state = "unknown"
description = entry.get("description")
checks.append({
"name": name.strip()[:120],
"state": state,
"description": description.strip()[:240] if isinstance(description, str) else "",
"url": _safe_gitea_web_url(entry.get("target_url")),
"_index": index,
})
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
def _normalize_notifications(threads: Any) -> list[dict]:
if not isinstance(threads, list):
raise ValueError("Gitea notification response was not a list")
@@ -671,6 +735,60 @@ async def mark_notification_read(thread_id: int) -> None:
response.raise_for_status()
async def acknowledge_notification(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
repository_name = repository.get("full_name")
subject_path = _gitea_api_path(subject.get("url"))
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
subject_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
comment_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
)
if (
not subject_match
or not comment_match
or subject_match.group(1) != repository_name
or comment_match.group(1) != repository_name
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification has no supported latest comment")
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Authenticated Gitea user is unavailable")
reaction_path = f"/api/v1/{comment_path}/reactions"
response = await _get_client().get(reaction_path, headers=_auth())
response.raise_for_status()
reactions = response.json()
existing = any(
isinstance(reaction, dict)
and reaction.get("content") == "+1"
and isinstance(reaction.get("user"), dict)
and reaction["user"].get("login") == login
for reaction in (reactions if isinstance(reactions, list) else [])
)
if not existing:
response = await _get_client().post(
reaction_path, headers=_auth(), json={"content": "+1"}
)
response.raise_for_status()
await mark_notification_read(thread_id)
return {
"id": thread_id,
"reaction": "existing" if existing else "created",
"status": "read",
}
def _gitea_api_path(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -699,6 +817,9 @@ async def notification_detail(thread_id: int) -> dict:
conversation_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
comment_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
)
repository_name = repository.get("full_name")
supported_conversation = (
conversation_match
@@ -774,6 +895,11 @@ async def notification_detail(thread_id: int) -> dict:
"url": latest_url,
},
"issue": issue,
"acknowledge_supported": bool(
supported_conversation
and comment_match
and comment_match.group(1) == repository_name
),
"conversation": conversation,
}
@@ -832,6 +958,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
return _normalize_issue_comment(comment)
async def upload_notification_attachment(
thread_id: int, filename: str, content_type: str, content: bytes
) -> dict:
"""Upload to the exact issue or pull identified by a trusted notification."""
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
subject_path = _gitea_api_path(subject.get("url"))
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
if (
not match
or match.group(1) != repository.get("full_name")
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
# Gitea stores pull-request assets on its shared issue asset endpoint.
response = await _get_client().post(
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets",
headers=_auth(), params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def close_issue(repository: str, number: int) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
@@ -905,6 +1068,33 @@ async def upload_assigned_issue_attachment(
return {"name": name, "url": url, "size": size}
async def upload_assigned_pull_attachment(
repository: str,
number: int,
filename: str,
content_type: str,
content: bytes,
) -> dict:
if not await is_assigned_pull(repository, number):
raise IssueNotAvailableError("Assigned pull request not found")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/assets",
headers=_auth(),
params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def repo_labels(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/labels",
@@ -1485,6 +1675,73 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
return dependencies
async def mutate_assigned_issue_dependency(
repository: str,
number: int,
blocker_repository: str,
blocker_number: int,
remove: bool = False,
) -> dict:
"""Add or remove one prerequisite and return the canonical open dependency set."""
if (repository, number) == (blocker_repository, blocker_number):
raise IssueDependencyInvalidError("an issue cannot block itself")
login, source = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
if (
source.get("state") != "open"
or isinstance(source.get("pull_request"), dict)
or not _login_in_users(login, source.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
current = await issue_dependencies(repository, number)
currently_present = any(
item["repository"] == blocker_repository and item["number"] == blocker_number
for item in current
)
if currently_present != remove:
return {
"repository": repository,
"number": number,
"dependencies_available": True,
"dependencies": current,
}
if not remove:
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
if (
not isinstance(candidate, dict)
or candidate.get("number") != blocker_number
or candidate.get("state") != "open"
or isinstance(candidate.get("pull_request"), dict)
):
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
owner, repo = blocker_repository.split("/", 1)
response = await _get_client().request(
"DELETE" if remove else "POST",
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
headers=_auth(),
json={"owner": owner, "repo": repo, "index": blocker_number},
)
response.raise_for_status()
confirmed = await issue_dependencies(repository, number)
present = any(
item["repository"] == blocker_repository and item["number"] == blocker_number
for item in confirmed
)
if present == remove:
raise ValueError("Gitea did not confirm the dependency change")
return {
"repository": repository,
"number": number,
"dependencies_available": True,
"dependencies": confirmed,
}
async def update_assigned_issue(
repository: str,
number: int,
@@ -1699,6 +1956,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"files": [
{
"filename": item.get("filename", ""),
@@ -1721,6 +1979,30 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
async def pull_check_status(repository: str, number: int) -> dict:
"""Load only mutable pull and CI state, without immutable review data."""
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
return {
"repository": repository,
"number": number,
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
}
async def is_pull_merged_at_head(
repository: str, number: int, expected_head_sha: str
) -> bool:
@@ -1831,6 +2113,7 @@ async def pull_review_detail(repository: str, number: int) -> dict:
"author": user.get("login", ""),
"head_sha": sha,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"files": normalized_files,
"reviews": normalized_reviews,
}
+37
View File
@@ -31,6 +31,19 @@ class LiveSnapshotState:
lease_expires_at: float | None
@dataclass(frozen=True)
class LiveSnapshotMetadata:
"""Shared snapshot coordination fields without the potentially large value."""
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
class LiveSnapshotStore:
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
@@ -145,6 +158,30 @@ class LiveSnapshotStore:
lease_expires_at=lease["expires_at"] if lease else None,
)
def load_metadata(self) -> LiveSnapshotMetadata:
"""Load freshness and coordination state without reading the snapshot value."""
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"""SELECT created_at_json, failure_count_json, retry_at_json,
revisions_json, generation FROM live_snapshot WHERE singleton = 1"""
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotMetadata(
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish_refresh(
self,
owner: str,
+146 -1
View File
@@ -49,6 +49,9 @@ class LoginAttemptStore:
window_seconds: int,
max_entries: int = 10_000,
lock_timeout_seconds: float = 0.1,
alert_bucket_seconds: int = 60 * 60,
alert_retention_seconds: int = 30 * 24 * 60 * 60,
max_alert_buckets: int = 720,
) -> None:
self.path = Path(path)
self.clock = clock
@@ -56,6 +59,9 @@ class LoginAttemptStore:
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
self.lock_timeout_seconds = lock_timeout_seconds
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
self.alert_retention_seconds = max(1, alert_retention_seconds)
self.max_alert_buckets = max(1, max_alert_buckets)
@staticmethod
def _digest(source: str) -> str:
@@ -74,6 +80,30 @@ class LoginAttemptStore:
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_admissions (
bucket TEXT NOT NULL,
source_hash TEXT NOT NULL,
attempts INTEGER NOT NULL,
window_started_at REAL NOT NULL,
PRIMARY KEY (bucket, source_hash)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_alerts (
method TEXT NOT NULL,
bucket_started_at INTEGER NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
blocked_count INTEGER NOT NULL DEFAULT 0,
first_at INTEGER NOT NULL,
last_at INTEGER NOT NULL,
PRIMARY KEY (method, bucket_started_at)
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
@@ -97,7 +127,81 @@ class LoginAttemptStore:
remaining = row[1] + self.window_seconds - now
return max(0, math.ceil(remaining))
def record_failure(self, source: str) -> None:
def admit(self, bucket: str, source: str, *, limit: int) -> int:
"""Consume one fixed-window admission or return seconds until retry."""
now = self.clock()
source_hash = self._digest(source)
limit = max(1, limit)
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
(self.window_seconds, now),
)
row = connection.execute(
"SELECT attempts, window_started_at FROM login_admissions "
"WHERE bucket = ? AND source_hash = ?",
(bucket, source_hash),
).fetchone()
if row is not None and row[0] >= limit:
return max(1, math.ceil(row[1] + self.window_seconds - now))
if row is None:
connection.execute(
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
(bucket, source_hash, now),
)
else:
connection.execute(
"UPDATE login_admissions SET attempts = attempts + 1 "
"WHERE bucket = ? AND source_hash = ?",
(bucket, source_hash),
)
connection.execute(
"""
DELETE FROM login_admissions
WHERE (bucket, source_hash) NOT IN (
SELECT bucket, source_hash FROM login_admissions
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_entries,),
)
return 0
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def _record_alert(
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
) -> None:
safe_method = method if method in {"token", "passkey"} else "unknown"
occurred_at = int(now)
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
failed = 0 if blocked else 1
blocked_count = 1 if blocked else 0
connection.execute(
"""
INSERT INTO login_alerts(
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
failed_count = failed_count + excluded.failed_count,
blocked_count = blocked_count + excluded.blocked_count,
first_at = MIN(first_at, excluded.first_at),
last_at = MAX(last_at, excluded.last_at)
""",
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
)
connection.execute(
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
"(SELECT method, bucket_started_at FROM login_alerts "
"ORDER BY bucket_started_at DESC LIMIT ?)",
(self.max_alert_buckets,),
)
def record_failure(self, source: str, *, method: str = "token") -> None:
now = self.clock()
source_hash = self._digest(source)
try:
@@ -131,11 +235,52 @@ class LoginAttemptStore:
""",
(self.max_entries,),
)
self._record_alert(connection, method, blocked=False, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def record_blocked(self, *, method: str = "token") -> None:
now = self.clock()
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
self._record_alert(connection, method, blocked=True, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
bounded_limit = min(100, max(1, limit))
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM login_alerts WHERE last_at < ?",
(now - self.alert_retention_seconds,),
)
rows = connection.execute(
"SELECT method, failed_count, blocked_count, first_at, last_at "
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
(bounded_limit,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
return [
{
"method": row[0],
"failed_count": row[1],
"blocked_count": row[2],
"first_at": row[3],
"last_at": row[4],
}
for row in rows
]
def clear(self, source: str) -> None:
try:
with self._connect() as connection:
+910 -68
View File
File diff suppressed because it is too large. Load diff
+133 -7
View File
@@ -16,12 +16,22 @@ class StoredPasskey:
sign_count: int
device_label: str
management_id: str
created_at: int
class PasskeyStore:
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_challenges: int = 10_000,
max_challenges_per_source: int = 10,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_challenges = max(1, max_challenges)
self.max_challenges_per_source = max(1, max_challenges_per_source)
@staticmethod
def _digest(value: bytes | str) -> str:
@@ -49,6 +59,7 @@ class PasskeyStore:
CREATE TABLE IF NOT EXISTS passkey_challenges (
challenge_hash TEXT PRIMARY KEY,
session_hash TEXT,
source_hash TEXT,
purpose TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
@@ -56,6 +67,14 @@ class PasskeyStore:
)
"""
)
columns = {
row[1]
for row in connection.execute("PRAGMA table_info(passkey_challenges)")
}
if "source_hash" not in columns:
connection.execute(
"ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
@@ -68,25 +87,50 @@ class PasskeyStore:
purpose: str,
action: str,
target: str,
source: str | None = None,
ttl_seconds: int = 120,
) -> None:
now = int(self.clock())
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
connection.execute(
"INSERT INTO passkey_challenges("
"challenge_hash, session_hash, purpose, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?, ?)",
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?, ?, ?)",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
source_hash,
purpose,
action,
target,
now + max(1, ttl_seconds),
),
)
connection.execute(
"""
DELETE FROM passkey_challenges
WHERE source_hash = ? AND challenge_hash NOT IN (
SELECT challenge_hash FROM passkey_challenges
WHERE source_hash = ?
ORDER BY expires_at DESC, rowid DESC LIMIT ?
)
""",
(source_hash, source_hash, self.max_challenges_per_source),
)
connection.execute(
"""
DELETE FROM passkey_challenges
WHERE challenge_hash NOT IN (
SELECT challenge_hash FROM passkey_challenges
ORDER BY expires_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_challenges,),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
@@ -150,7 +194,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 +205,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 +213,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 +237,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 +289,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
+18 -3
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"})
@@ -16,8 +17,10 @@ def request_body_limit(method: str, path: str) -> int | None:
return SESSION_BODY_LIMIT
if (
normalized_method == "POST"
and path.startswith("/api/v1/repos/")
and "/issues/" in path
and (
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
or path.startswith("/api/v1/notifications/")
)
and path.endswith("/attachments")
):
return ISSUE_ATTACHMENT_BODY_LIMIT
@@ -50,6 +53,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 +85,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,
)
+16 -4
View File
@@ -12,6 +12,17 @@ class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely."""
class SessionStatus(str):
"""String-compatible status carrying the server-confirmed idle deadline."""
idle_expires_at: int | None
def __new__(cls, value: str, idle_expires_at: int | None = None):
instance = super().__new__(cls, value)
instance.idle_expires_at = idle_expires_at
return instance
@dataclass(frozen=True)
class ActiveDevice:
management_id: str
@@ -180,10 +191,11 @@ class SessionStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] != expires_at or expires_at <= now:
return "revoked"
if row[1] + max(1, idle_timeout_seconds) <= now:
return "idle"
return "active"
return SessionStatus("revoked")
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
if idle_expires_at <= now:
return SessionStatus("idle", idle_expires_at)
return SessionStatus("active", idle_expires_at)
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
+17
View File
@@ -69,3 +69,20 @@ async def runtime_bundle(digest: str, request: Request) -> Response:
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
@router.get("/feature-{name}-{digest}.js")
async def feature_bundle(name: str, digest: str, request: Request) -> Response:
bundle = FRONTEND_BUILD.feature_bundles.get(name)
if bundle is None or digest != bundle.runtime_digest:
raise HTTPException(status_code=404, detail="Feature revision not found")
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
return Response(
bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes,
media_type="application/javascript",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept-Encoding",
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
+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,)
+99 -5
View File
@@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
}},
}});
const queued = outbox.enqueue({{
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
}});
const restored = createAuthoredOutbox({{storage}}).list()[0];
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["queued"]["blockerRepository"] == "stackchain/api"
assert output["queued"]["blockerNumber"] == 9
assert output["queued"]["present"] is False
assert output["restored"]["present"] is False
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
"method": "PATCH",
"key": "blocker-op",
"body": {"repository": "stackchain/api", "number": 9, "present": False},
}]
assert output["result"]["confirmed"][0]["dependencies"] == []
assert output["remaining"] == []
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -480,6 +515,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 +562,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 +572,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 +590,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"
@@ -545,3 +613,29 @@ const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',background
"""
output = run_node(script)
assert output == {"error": "IndexedDB unavailable", "items": []}
def test_pull_screenshot_comment_persists_blob_only_in_indexeddb():
script = f"""
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const mirrors=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
}}}});
(async()=>{{const blob=new Blob(['PULL-PRIVATE-BYTES'],{{type:'image/webp'}});
await outbox.enqueueDurably({{kind:'pull-comment',repository:'stackchain/web',number:31,
body:'Mobile proof',operationId:'pull-image-31',
attachment:{{filename:'proof.webp',contentType:'image/webp',blob}}}});
const local=values.get('stackchain.authored-outbox.v1');const durable=mirrors[0][0].attachment;
process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachment,
durable:{{isBlob:durable.blob instanceof Blob,text:await durable.blob.text()}}}}));
}})();
"""
output = run_node(script)
assert "PULL-PRIVATE-BYTES" not in output["local"]
assert output["metadata"] == {
"filename": "proof.webp", "contentType": "image/webp", "stored": True
}
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
+195 -8
View File
@@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_closed_app_sync_delivers_desired_blocker_state():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
}};
const state={{calls:[],completed:[]}};
const store={{
claimNext:async()=>item?{{...item}}:null,
complete:async id=>{{state.completed.push(id);item=null;}},
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
if(url==='api/v1/background-identity')return{{login:'timmy'}};
return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
}};
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
"""
output = run_node(script)
assert output["state"]["calls"][1] == {
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
"method": "PATCH",
"key": "blocker-1",
"body": {"repository": "stackchain/api", "number": 9, "present": False},
}
assert output["state"]["completed"] == ["blocker-1"]
assert output["result"]["confirmed"][0]["dependencies"] == []
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
const state={{completed:[],failed:[]}};
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
"""
output = run_node(script)
assert output["state"]["completed"] == []
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
assert output["result"]["attention"] == 1
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -82,7 +130,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 +164,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 +257,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}};
@@ -200,6 +285,71 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"] == [{"id": 91}]
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'pull-image',operationId:'pull-image',ownerLogin:'timmy',status:'queued',
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':JSON.parse(options.body)}});
if(url.endsWith('/attachments'))return{{markdown:'![proof.png](https://forge.example/proof.png)'}};
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}return{{id:77}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
const checkpoint=item.attachmentMarkdown;const result=await sync.flush();
process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
"""
output = run_node(script)
assert output["checkpoint"].startswith("![proof.png]")
assert [call["url"] for call in output["calls"]] == [
"api/v1/repos/stackchain/web/pulls/31/attachments",
"api/v1/repos/stackchain/web/pulls/31/comments",
"api/v1/repos/stackchain/web/pulls/31/comments",
]
assert [call["key"] for call in output["calls"]] == [
"pull-image:attachment", "pull-image:comment", "pull-image:comment"
]
assert output["calls"][-1]["body"] == {
"body": "Mobile proof\n\n![proof.png](https://forge.example/proof.png)"
}
assert output["result"]["confirmed"] == [{"id": 77}]
def test_pull_screenshot_comment_timeout_replays_one_upload_with_durable_stage_identity():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'pull-timeout',operationId:'pull-timeout',ownerLogin:'timmy',status:'queued',
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
calls.push({{url,key:options.headers?.['Idempotency-Key']}});
if(url.endsWith('/attachments'))return{{markdown:'![proof.png](https://forge.example/proof.png)'}};
if(commentAttempts++===0)return new Promise(()=>{{}});return{{id:78}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:5}});
let timedOut='';try{{await sync.flush();}}catch(error){{timedOut=error.message;}}
const checkpoint=item.attachmentMarkdown;const replay=await sync.flush();
process.stdout.write(JSON.stringify({{calls,timedOut,checkpoint,replay}}));}})();
"""
output = run_node(script)
assert output["timedOut"] == "Background request timed out."
assert output["checkpoint"].startswith("![proof.png]")
assert [call["key"] for call in output["calls"]] == [
"pull-timeout:attachment", "pull-timeout:comment", "pull-timeout:comment"
]
assert sum(call["url"].endswith("/attachments") for call in output["calls"]) == 1
assert output["replay"]["confirmed"] == [{"id": 78}]
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -372,6 +522,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 +620,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 +632,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-v87" in worker
+603 -9
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,10 +145,150 @@ 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 activity.json()["events"][1] == {
"id": activity.json()["events"][1]["id"],
"kind": "passkey_enrolled",
"method": "passkey",
"device_label": "Phone",
"target": "passkey",
"created_at": activity.json()["events"][1]["created_at"],
"status": "completed",
}
assert "stackchain_session=" in signed_in.headers["set-cookie"]
assert "correct horse battery staple" not in signed_in.text
@pytest.mark.anyio
async def test_passkey_options_are_source_limited_before_challenge_generation(
access_control, monkeypatch
):
monkeypatch.setenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "2")
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
)
first_source = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
other_source = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
async with httpx.AsyncClient(transport=first_source, base_url="https://test") as client:
first = await client.post("/api/v1/passkeys/authentication/options")
second = await client.post("/api/v1/passkeys/authentication/options")
blocked = await client.post("/api/v1/passkeys/authentication/options")
async with httpx.AsyncClient(transport=other_source, base_url="https://test") as client:
available = await client.post("/api/v1/passkeys/authentication/options")
assert first.status_code == second.status_code == available.status_code == 200
assert blocked.status_code == 429
assert blocked.json() == {"detail": "Too many passkey sign-in attempts"}
assert blocked.headers["retry-after"] == "60"
with sqlite3.connect(main._passkey_store().path) as connection:
assert connection.execute(
"SELECT COUNT(*) FROM passkey_challenges WHERE purpose = 'authentication'"
).fetchone() == (3,)
@pytest.mark.anyio
async def test_failed_passkey_verification_consumes_the_shared_sign_in_budget(
access_control,
):
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
failures = []
for _attempt in range(3):
options = await client.post("/api/v1/passkeys/authentication/options")
failures.append(
await client.post(
"/api/v1/passkeys/authentication/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "d3JvbmctY3JlZGVudGlhbA"},
"device_label": "Phone",
"action": "sign_in",
"target": "dashboard",
},
)
)
blocked = await client.post("/api/v1/passkeys/authentication/options")
assert [response.status_code for response in failures] == [401, 401, 401]
assert blocked.status_code == 429
assert blocked.headers["retry-after"] == "60"
@pytest.mark.anyio
async def test_successful_passkey_sign_in_clears_prior_source_failures(
access_control, monkeypatch
):
class VerifiedAuthentication:
new_sign_count = 1
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: VerifiedAuthentication(),
)
_signed, _managed_session = await asyncio.to_thread(
main.dashboard_auth.issue_session,
device_label="Phone",
management_id="phone-management-id",
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
)
source = "203.0.113.10"
attempts = main._login_attempt_store()
await asyncio.to_thread(attempts.record_failure, source)
await asyncio.to_thread(attempts.record_failure, source)
transport = httpx.ASGITransport(app=main.app, client=(source, 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
options = await client.post("/api/v1/passkeys/authentication/options")
signed_in = await client.post(
"/api/v1/passkeys/authentication/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"device_label": "Phone",
"action": "sign_in",
"target": "dashboard",
},
)
assert signed_in.status_code == 200, signed_in.text
with sqlite3.connect(attempts.path) as connection:
assert connection.execute(
"SELECT COUNT(*) FROM login_attempts WHERE source_hash = ?",
(attempts._digest(source),),
).fetchone() == (0,)
@pytest.mark.anyio
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
access_control, monkeypatch
@@ -190,6 +332,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 +359,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 +424,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)
@@ -426,6 +825,44 @@ async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_co
assert blocked.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_security_activity_surfaces_failed_and_blocked_token_sign_ins(access_control):
attacker_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
operator_transport = httpx.ASGITransport(app=main.app, client=("198.51.100.4", 1234))
async with httpx.AsyncClient(
transport=attacker_transport, base_url="https://test"
) as attacker:
failures = [
await attacker.post(
"/api/v1/session",
json={"access_token": "wrong", "device_label": "Unknown"},
)
for _ in range(4)
]
async with httpx.AsyncClient(
transport=operator_transport, base_url="https://test"
) as operator:
signed_in = await operator.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "Phone",
},
)
activity = await operator.get("/api/v1/security-events")
assert [response.status_code for response in failures] == [401, 401, 401, 429]
assert signed_in.status_code == 200
assert activity.status_code == 200
assert activity.headers["cache-control"] == "no-store"
alerts = activity.json()["authentication_alerts"]
assert len(alerts) == 1
assert alerts[0]["method"] == "token"
assert alerts[0]["failed_count"] == 3
assert alerts[0]["blocked_count"] == 1
assert alerts[0]["first_at"] <= alerts[0]["last_at"]
@pytest.mark.anyio
async def test_successful_sign_in_clears_prior_failures(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
@@ -514,9 +951,39 @@ async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease
assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time())
assert payload["idle_expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_session_status_reports_server_idle_deadline_without_extending_it(
access_control, monkeypatch
):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
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"}
)
expected_last_active = int(time.time()) - 120
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
connection.execute(
"UPDATE active_sessions SET last_active_at = ?", (expected_last_active,)
)
first = await client.get("/api/v1/session")
second = await client.get("/api/v1/session")
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
stored_last_active = connection.execute(
"SELECT last_active_at FROM active_sessions"
).fetchone()[0]
assert first.status_code == 200
assert first.json()["idle_expires_at"] == expected_last_active + 900
assert second.json()["idle_expires_at"] == expected_last_active + 900
assert stored_last_active == expected_last_active
@pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -557,6 +1024,7 @@ async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(acce
@pytest.mark.anyio
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", "100")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
@@ -569,10 +1037,11 @@ async def test_activity_heartbeat_extends_the_server_idle_deadline(access_contro
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
status = await client.get("/api/v1/session")
assert heartbeat.status_code == 200
assert heartbeat.json()["active"] is True
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
assert heartbeat.json()["idle_expires_at"] == status.json()["expires_at"]
@pytest.mark.anyio
@@ -695,6 +1164,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 +1453,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 +1473,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
@@ -1002,6 +1593,9 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
time.sleep(0.15)
return 17
def record_blocked(self, *, method):
assert method == "token"
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
+205 -9
View File
@@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -59,7 +59,7 @@ const boundary = createSessionBoundary({{
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
now: () => state.now,
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}});
@@ -260,22 +260,102 @@ process.stdout.write(JSON.stringify(state));
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["savedIdleExpiry"] == "4102441200"
assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
{
"type": "stackchain-session-lease",
"expiresAt": 4102444800,
"idleExpiresAt": 4102441200,
}
]
assert result["leaseDelay"] > 0
def test_expired_idle_lease_locks_offline_without_purging_private_work():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '200');
storage.setItem('stackchain.session-idle-expires-at', '99');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == [
"stackchain.private",
"gitea.preference",
"stackchain.session-expires-at",
"stackchain.session-idle-expires-at",
]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '4102444800');
state.responsePayload = {active:true, idle_expires_at:4102441300};
const recorded = await boundary.recordActivity();
state.recorded = recorded;
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["recorded"] is True
assert result["savedIdleExpiry"] == "4102441300"
assert result["workerMessages"] == [
{
"type": "stackchain-session-lease",
"expiresAt": 4102444800,
"idleExpiresAt": 4102441300,
}
]
def test_absolute_expiry_still_purges_after_an_idle_lock():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150};
await boundary.refreshOfflineLease();
state.now = 150000;
await state.timers[1].callback();
state.deletedAtIdle = [...state.deletedDatabases];
state.now = 200000;
await state.timers[0].callback();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["deletedAtIdle"] == []
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
"/dashboard/login?reason=session-idle",
"/dashboard/login?reason=session-expired",
]
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario(
"""
@@ -291,7 +371,9 @@ process.stdout.write(JSON.stringify(state));
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
@@ -419,7 +501,9 @@ process.stdout.write(JSON.stringify(state));
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True
@@ -439,7 +523,9 @@ process.stdout.write(JSON.stringify(state));
)
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
@@ -532,7 +618,9 @@ process.stdout.write(JSON.stringify(state));
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
assert result["assignedAfterDeletion"] is True
@@ -596,6 +684,108 @@ 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_formats_failed_sign_in_alerts_for_mobile_scanability():
result = run_session_scenario(
"""
state.formatted = boundary.formatAuthenticationAlert({
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["formatted"] == {
"title": "12 failed token sign-ins · 3 blocked",
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
}
def test_security_activity_renders_authentication_alerts_before_journal_events():
source = SESSION_JS.read_text()
alerts = source.index("page.authentication_alerts.forEach")
events = source.index("page.events.forEach")
assert alerts < events
assert "boundary.formatAuthenticationAlert(alert)" in source
assert "row.className = 'security-event authentication-alert'" in source
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".authentication-alert { border-color:#d69e2e;" in css
def test_security_activity_explains_pending_outcome_confirmation():
source = SESSION_JS.read_text()
assert "Outcome confirmation pending" in source
assert "event.status === 'pending'" in source
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
source = SESSION_JS.read_text()
assert "passkey_enrolled: 'Passkey enrolled'" in source
assert "title.textContent = labels[event.kind]" in source
assert "details.textContent =" 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 +807,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))});
+91
View File
@@ -0,0 +1,91 @@
import json
import subprocess
from pathlib import Path
LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js"
def run_loader(scenario: str) -> dict:
harness = f"""
const fs=require('fs'); const vm=require('vm');
const state={{appends:0, removed:0}};
const document={{
head:{{appendChild(node){{ state.appends++; state.node=node; }} }},
createElement(){{ return {{remove(){{state.removed++;}}}}; }},
}};
const context={{document, setTimeout, clearTimeout, console}};
context.globalThis=context;
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context);
const createFeatureLoader=context.createFeatureLoader;
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_concurrent_feature_requests_share_one_script():
result = run_loader("""
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
const first=loader.load('issue-capture'); const second=loader.load('issue-capture');
state.node.onload(); await Promise.all([first,second]);
console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')}));
""")
assert result == {"appends": 1, "same": True, "ready": True}
def test_failed_feature_request_can_retry_without_reload():
result = run_loader("""
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror();
const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry;
console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')}));
""")
assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True}
def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls():
result = run_loader("""
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;});
const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror();
const first=await failed;
const failedState={disabled:trigger.disabled,status:status.textContent,opened};
const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload();
const second=await retry;
console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent}));
""")
assert result == {
"loading": {"disabled": True, "status": "Loading issue capture…"},
"failedState": {
"disabled": False,
"status": "Issue capture could not load. Tap New issue to retry.",
"opened": 0,
},
"first": False,
"second": True,
"opened": 1,
"finalStatus": "",
}
def test_pull_workspace_failure_is_accessible_and_retryable_from_same_card():
result = run_loader("""
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
const loader=createFeatureLoader({document, urls:{'pull-workflow':'feature-pull.js'}, timeoutMs:100});
const failed=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
state.node.onerror(); const first=await failed;
const failedStatus=status.textContent;
const retry=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
state.node.onload(); const second=await retry;
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
""")
assert result == {
"first": False,
"second": True,
"failedStatus": "Pull workspace could not load. Tap the work card to retry.",
"opened": 1,
"appends": 2,
}
+111 -1
View File
@@ -9,7 +9,7 @@ from starlette.requests import Request
from src import main
from src.frontend_bundle import build_frontend
from src.views import dashboard, runtime_bundle, service_worker
from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
@@ -42,6 +42,116 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert changed.runtime_bytes != first.runtime_bytes
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes
assert b"function createIssueCapture" in capture.runtime_bytes
assert b"function createPullSheet" not in first.runtime_bytes
assert b"function createReviewController" not in first.runtime_bytes
assert b"function createPullSheet" in pull_workflow.runtime_bytes
assert b"function createReviewController" in pull_workflow.runtime_bytes
assert len(first.runtime_gzip_bytes) <= 95 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
capture_source = changed_frontend / "create-issue-sheet.js"
capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")
changed = build_frontend(changed_frontend)
assert changed.runtime_name == first.runtime_name
assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name
pull_source = changed_frontend / "pull-sheet.js"
pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n")
pull_changed = build_frontend(changed_frontend)
assert pull_changed.runtime_name == first.runtime_name
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
@pytest.mark.anyio
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
build = build_frontend(FRONTEND)
capture = build.feature_bundles["issue-capture"]
request = Request(
{"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
"headers": [(b"accept-encoding", b"identity")]}
)
response = await feature_bundle("issue-capture", capture.runtime_digest, request)
assert response.body == capture.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
with pytest.raises(Exception) as rejected:
await feature_bundle("issue-capture", "missing", request)
assert rejected.value.status_code == 404
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
first = build_frontend(FRONTEND)
changed_frontend = tmp_path / "frontend"
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-v87';",
"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)
+1
View File
@@ -182,6 +182,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"conversation": {
"comments": [{
"id": 9,
+246
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 = []
@@ -1883,3 +1939,193 @@ async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch):
calls = []
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
calls.append((repository, number, blocker_repository, blocker_number, remove))
return {
"repository": repository, "number": number, "dependencies_available": True,
"dependencies": [] if remove else [{
"repository": blocker_repository, "number": blocker_number,
"title": "Restore API", "state": "open",
}],
}
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
added = await client.post(
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
json={"repository": "stackchain/api", "number": 9},
)
removed = await client.request(
"DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
json={"repository": "stackchain/api", "number": 9},
)
assert [added.status_code, removed.status_code] == [200, 200]
assert added.headers["cache-control"] == "no-store"
assert added.json()["dependencies"][0]["number"] == 9
assert removed.json()["dependencies"] == []
assert calls == [
("stackchain/dashboard", 17, "stackchain/api", 9, False),
("stackchain/dashboard", 17, "stackchain/api", 9, True),
]
@pytest.mark.anyio
async def test_assigned_issue_blocker_patch_converges_to_desired_state(monkeypatch):
calls = []
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
calls.append((repository, number, blocker_repository, blocker_number, remove))
return {
"repository": repository, "number": number, "dependencies_available": True,
"dependencies": [] if remove else [{"repository": blocker_repository, "number": blocker_number}],
}
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
headers={"Idempotency-Key": "offline-blocker-1"},
json={"repository": "stackchain/api", "number": 9, "present": False},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["dependencies"] == []
assert calls == [("stackchain/dashboard", 17, "stackchain/api", 9, True)]
@pytest.mark.anyio
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
async def mutate(*_args, **_kwargs):
raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue")
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
json={"repository": "stackchain/api", "number": 9},
)
assert response.status_code == 422
assert response.json() == {"error": "blocker must be an open issue"}
@pytest.mark.anyio
async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation():
requests = []
async def handler(request):
requests.append(request)
path = request.url.path
if path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
return httpx.Response(200, json={
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
})
if path == "/api/v1/repos/stackchain/api/issues/9":
return httpx.Response(200, json={"number": 9, "state": "open"})
if request.method == "GET" and path.endswith("/dependencies"):
mutated = any(r.method == "POST" for r in requests)
return httpx.Response(200, json=[{
"number": 9, "state": "open", "title": "Restore API",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/9",
}] if mutated else [])
if request.method == "POST" and path.endswith("/dependencies"):
return httpx.Response(201, json={})
raise AssertionError((request.method, path))
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.mutate_assigned_issue_dependency(
"stackchain/dashboard", 17, "stackchain/api", 9
)
finally:
await gitea_proxy.stop_client()
mutation = next(request for request in requests if request.method == "POST")
assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies"
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
assert result["dependencies"] == [{
"repository": "stackchain/api", "number": 9, "title": "Restore API",
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
}]
@pytest.mark.anyio
async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
mutations = []
dependency = {
"number": 9, "state": "open", "title": "Restore API",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/9",
}
async def handler(request):
path = request.url.path
if path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
return httpx.Response(200, json={
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
})
if path == "/api/v1/repos/stackchain/api/issues/9":
return httpx.Response(200, json={"number": 9, "state": "open"})
if request.method == "GET" and path.endswith("/dependencies"):
return httpx.Response(200, json=[dependency])
if path.endswith("/dependencies"):
mutations.append(request.method)
return httpx.Response(201, json={})
raise AssertionError((request.method, path))
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.mutate_assigned_issue_dependency(
"stackchain/dashboard", 17, "stackchain/api", 9
)
finally:
await gitea_proxy.stop_client()
assert mutations == []
assert result["dependencies"][0]["number"] == 9
@pytest.mark.anyio
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
])
async def test_gitea_dependency_rejects_invalid_candidates(
candidate, blocker_repository, blocker_number, existing
):
async def handler(request):
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17":
return httpx.Response(200, json={
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
})
if request.url.path.endswith("/dependencies"):
return httpx.Response(200, json=existing)
return httpx.Response(200, json=candidate)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.IssueDependencyInvalidError):
await gitea_proxy.mutate_assigned_issue_dependency(
"stackchain/dashboard", 17, blocker_repository, blocker_number
)
finally:
await gitea_proxy.stop_client()
+284 -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,110 @@ 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_pending_comment_admission_locks_attachment_replacement_and_remove_controls():
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; }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const controller=attachment.mount({{input,preview,image,meta,remove,status,
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
controller.setBusy(true);
const pending={{input:input.disabled,remove:remove.disabled}};
controller.setBusy(false);
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
"""
assert json.loads(run_node(script)) == {
"pending": {"input": True, "remove": True},
"released": {"input": False, "remove": False},
}
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 +437,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 +458,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
@@ -262,3 +472,50 @@ def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_adv
assert "await authoredOutbox.enqueueDurably(message)" in source
assert "await issueCommentNext.admit(item, message)" in source
assert "navigator.onLine === false" in source
def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_optimizer():
html = INDEX.read_text()
source = DASHBOARD.read_text()
assert 'id="pull-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'for="pull-attachment"' in html
assert 'id="pull-attachment-preview"' in html
assert 'id="pull-attachment-image"' in html
assert 'id="remove-pull-attachment"' in html
assert "const pullAttachmentController = issueAttachment.mount({" in source
assert "pullAttachmentController.clear();" in source
def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_it():
source = DASHBOARD.read_text()
open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
"\n function closePullSheet", 1
)[0]
assert "if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
source = DASHBOARD.read_text()
assert "async function queuePullScreenshotComment" in source
assert "attachment: await pullAttachmentController.serialize()" in source
assert "await authoredOutbox.enqueueDurably(message)" in source
assert "navigator.onLine === false" in source
assert "Your comment and screenshot are safe; retry." in source
def test_pull_screenshot_foreground_and_replay_share_the_durable_operation_pipeline():
source = DASHBOARD.read_text()
standard = source.split(
"qs('#send-pull-comment').addEventListener('click', async () => {", 1
)[1].split("\n });", 1)[0]
comment_next = source.split("async function submitCommentAndNext(kind) {", 1)[1].split(
"\n }\n qs('#send-issue-comment-next')", 1
)[0]
assert "await queuePullScreenshotComment(item, body, operationId, false, true)" in standard
assert "pullAttachmentController.prepareComment" not in standard
assert "attachmentController.state() && (kind === 'pull' || navigator.onLine === false)" in comment_next
+96
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 = []
@@ -376,3 +402,73 @@ async def test_attachment_endpoint_rejects_unsafe_or_mismatched_filename(monkeyp
assert [traversal.status_code, mismatch.status_code] == [422, 422]
assert called is False
@pytest.mark.anyio
async def test_pull_attachment_endpoint_uploads_binary_to_exact_assigned_pull(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/pull.png",
"size": len(content),
}
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_pull_attachment", upload, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/web/pulls/31/attachments",
files={"file": ("pull.png", PNG_BYTES, "image/png")},
headers={"Idempotency-Key": "pull-image-31:attachment"},
)
replay = await client.post(
"/api/v1/repos/stackchain/web/pulls/31/attachments",
files={"file": ("pull.png", PNG_BYTES, "image/png")},
headers={"Idempotency-Key": "pull-image-31:attachment"},
)
assert response.status_code == replay.status_code == 201
assert replay.json() == response.json()
assert response.json()["markdown"] == (
"![pull.png](<https://forge.example/attachments/pull.png>)"
)
assert calls == [("stackchain/web", 31, "pull.png", "image/png", PNG_BYTES)]
@pytest.mark.anyio
async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_upload():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET":
return httpx.Response(200, json={
"state": "open", "assignees": [{"login": "timmy"}],
})
return httpx.Response(201, json={
"name": "pull.png", "size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/pull.png",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.upload_assigned_pull_attachment(
"stackchain/web", 31, "pull.png", "image/png", PNG_BYTES
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/web/pulls/31"),
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
]
assert PNG_BYTES in requests[-1].content
assert result["url"] == "https://forge.example/attachments/pull.png"
+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-v87" in source
assert "BASE + 'static/later-sync.js'" in source
+42
View File
@@ -137,6 +137,48 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
assert "freshness" in second
@pytest.mark.anyio
async def test_warm_unchanged_polls_only_load_shared_metadata(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
first = payload(await main.live_snapshot())
full_load = main._live_snapshot_store.load
metadata_load = main._live_snapshot_store.load_metadata
calls = {"full": 0, "metadata": 0}
def counted_full_load():
calls["full"] += 1
return full_load()
def counted_metadata_load():
calls["metadata"] += 1
return metadata_load()
monkeypatch.setattr(main._live_snapshot_store, "load", counted_full_load)
monkeypatch.setattr(main._live_snapshot_store, "load_metadata", counted_metadata_load)
for _ in range(10):
response = await main.live_snapshot(
context_revision=first["revisions"]["context"],
events_revision=first["revisions"]["events"],
notifications_revision=first["revisions"]["notifications"],
)
assert response.status_code == 200
assert "context" not in payload(response)
assert calls == {"full": 0, "metadata": 10}
@pytest.mark.anyio
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch, tmp_path):
async def user():
+21
View File
@@ -1,5 +1,6 @@
import threading
import os
import sqlite3
import pytest
@@ -7,6 +8,26 @@ from src import live_snapshot_store
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
with sqlite3.connect(store.path) as connection:
connection.execute(
"UPDATE live_snapshot SET value_json = ? WHERE singleton = 1",
("not-json",),
)
metadata = store.load_metadata()
assert metadata.created_at == {
section: None for section in ("context", "events", "notifications")
}
assert metadata.revisions == {
section: 0 for section in ("context", "events", "notifications")
}
with pytest.raises(ValueError):
store.load()
def test_independent_stores_racing_for_refresh_have_one_lease_winner(tmp_path):
path = tmp_path / "live.sqlite3"
first = LiveSnapshotStore(path, clock=lambda: 100.0)
+119
View File
@@ -72,3 +72,122 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
def test_failed_sign_ins_are_aggregated_across_workers_and_survive_throttle_clear(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
first = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_bucket_seconds=300,
)
second = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_bucket_seconds=300,
)
first.record_failure("203.0.113.7", method="token")
now[0] = 1_010.0
second.record_failure("198.51.100.4", method="token")
first.clear("203.0.113.7")
assert second.list_alerts() == [
{
"method": "token",
"failed_count": 2,
"blocked_count": 0,
"first_at": 1_000,
"last_at": 1_010,
}
]
def test_rate_blocked_sign_ins_increment_a_distinct_privacy_safe_count(tmp_path):
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: 1_000.0,
max_failures=3,
window_seconds=60,
)
store.record_blocked(method="token")
assert store.list_alerts() == [
{
"method": "token",
"failed_count": 0,
"blocked_count": 1,
"first_at": 1_000,
"last_at": 1_000,
}
]
with sqlite3.connect(store.path) as connection:
columns = [row[1] for row in connection.execute("PRAGMA table_info(login_alerts)")]
assert "source_hash" not in columns
def test_expired_sign_in_alert_buckets_are_not_returned(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_retention_seconds=300,
)
store.record_failure("203.0.113.7")
now[0] = 1_301.0
assert store.list_alerts() == []
def test_sign_in_alert_bucket_count_is_bounded_during_rotating_source_floods(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_bucket_seconds=60,
max_alert_buckets=2,
)
for index, timestamp in enumerate((1_000.0, 1_060.0, 1_120.0)):
now[0] = timestamp
store.record_failure(f"203.0.113.{index}")
with sqlite3.connect(store.path) as connection:
count = connection.execute("SELECT COUNT(*) FROM login_alerts").fetchone()[0]
assert count == 2
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
first = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
second = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 0
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 60
assert first.admit("passkey_options", "203.0.113.8", limit=2) == 0
now[0] = 1_060.0
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
+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-v87" 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-v87" 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",
},
}
+514 -11
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():
@@ -693,6 +811,77 @@ controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
storage:null,
fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
}});
const item = {{repository:'stackchain/dashboard', number:17}};
const blocker = {{repository:'stackchain/api', number:9}};
const first = controller.updateBlocker(item, blocker, false);
const duplicate = controller.updateBlocker(item, blocker, false);
finish({{repository:item.repository, number:item.number, dependencies_available:true,
dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
same:first === duplicate, result:results[0]
}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["same"] is True
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
"method": "POST",
"body": {"repository": "stackchain/api", "number": 9},
}]
assert output["result"]["dependencies"][0]["number"] == 9
def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{
storage:null,
fetchJson:async () => ({{dependencies_available:true,
dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
}});
controller.updateBlocker(
{{repository:'stackchain/dashboard',number:17}},
{{repository:'stackchain/api',number:9}}, true
).then(() => process.stdout.write('unexpected'))
.catch(error => process.stdout.write(error.message));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert result.stdout == "Blocker change was not confirmed."
@pytest.mark.anyio
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
html = await dashboard()
assert 'id="manage-issue-blockers"' in html
assert 'id="issue-blocker-search" type="search"' in html
assert 'id="issue-blocker-results" role="listbox"' in html
assert 'id="cancel-issue-blocker"' in html
assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
assert "api('api/v1/search?q='" in html
assert "result.kind === 'issue' && result.state === 'open'" in html
assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
assert "width:100%; max-width:100%; box-sizing:border-box" in html
assert "overflow-wrap:anywhere" in html
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -2397,6 +2586,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 +2751,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))});
@@ -3059,6 +3312,68 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
]
def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const selection = buildMyWork.createNotificationSelection({{
limit: 2,
onChange: state => states.push(state),
}});
selection.start();
const first = selection.select(42);
const duplicate = selection.select(42);
selection.toggle(42);
selection.select(43);
selection.select(44);
const second = selection.select(45);
selection.cancel();
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["first"] == "selected"
assert output["duplicate"] == "already-selected"
assert output["second"] == "limit"
assert output["states"] == [
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
{"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
{"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
{"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
{"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
]
assert output["snapshot"] == output["states"][-1]
def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const selection = buildMyWork.createNotificationSelection({{
onChange: state => states.push(state),
}});
selection.start();
selection.select(42);
selection.select(43);
selection.select(44);
const retained = selection.retain([44, 44, 99]);
process.stdout.write(JSON.stringify({{retained, states}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["retained"] == {
"active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
}
assert output["states"][-1] == output["retained"]
def test_notification_pager_is_single_flight_and_merges_unique_updates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -3186,6 +3501,68 @@ reader.open(original[0], original).then(() =>
assert output["result"]["next"]["notification_id"] == 43
def test_notification_reader_acknowledges_once_and_opens_next_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'update', notification_id:42, has_update:true}},
{{kind:'update', notification_id:43, has_update:true}},
];
let release;
const calls = [];
const events = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => ({{id}}), markRead: async () => {{}},
acknowledge: id => new Promise(resolve => {{ calls.push(id); release = resolve; }}),
onOpen: item => events.push(['open', item.notification_id]), onDetail: () => {{}},
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
await reader.open(items[0]);
events.length = 0;
const first = reader.acknowledgeAndNext(items);
const duplicate = reader.acknowledgeAndNext(items);
await Promise.resolve();
release({{reaction:'created', status:'read'}});
const results = await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{calls, events, results}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [42]
assert output["events"] == [
["status", "Adding reaction and marking read…"],
["items", [43]],
["open", 43],
["status", "Loading update…"],
["status", "Update ready."],
]
assert output["results"][0]["next"]["notification_id"] == 43
assert output["results"][1] is False
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
html = await dashboard()
assert (
'id="acknowledge-update-next" type="button" hidden '
'aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>'
) in html
assert "qs('#acknowledge-update-next').hidden = true;" in html
assert "qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;" in html
assert "async function acknowledgeNotification(notificationId)" in html
assert "'/acknowledge', { method: 'POST'" in html
assert "acknowledge: acknowledgeNotification" in html
assert "notificationReader.acknowledgeAndNext(lastMyWork)" in html
assert "qs('#acknowledge-update-next').disabled = offline;" in html
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -3783,7 +4160,9 @@ const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const states = [
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc', checks:[
{{name:'lint', state:'failure'}}, {{name:'release', state:'pending'}}, {{name:'build', state:'success'}}
]}},
{{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
];
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
@@ -3792,7 +4171,7 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
output = json.loads(result.stdout)
assert output[0] == {"allowed": True, "reason": "Ready to merge"}
assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
assert output[2]["allowed"] is False and "CI" in output[2]["reason"]
assert output[2] == {"allowed": False, "reason": "CI blocked by lint, release"}
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
@@ -3871,6 +4250,38 @@ Promise.allSettled([first, concurrent]).then(async failed => {{
]
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v)}};
const calls = [];
const sheet = createPullSheet({{storage, fetchJson: url => {{
calls.push(url);
return Promise.resolve({{head_sha:'abc123', ci_state:calls.length === 1 ? 'pending' : 'success',
checks:[{{name:'lint', state:calls.length === 1 ? 'pending' : 'success'}}],
files:[{{filename:'src/api.py'}}]}});
}}}});
const item = {{repository:'stackchain/api', number:7}};
sheet.loadReview(item, 'abc123').then(first => {{
sheet.toggleReviewed(item, first, 'src/api.py');
return sheet.loadChecks(item).then(refreshed => {{
const combined = {{...first, ...refreshed}};
process.stdout.write(JSON.stringify({{calls, refreshed, progress:sheet.reviewState(item, combined)}}));
}});
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["calls"] == [
"api/v1/repos/stackchain/api/pulls/7/review-data",
"api/v1/repos/stackchain/api/pulls/7/checks",
]
assert output["refreshed"]["ci_state"] == "success"
assert output["progress"] == {"reviewed": ["src/api.py"], "total": 1, "complete": True}
@pytest.mark.anyio
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
html = await dashboard()
@@ -3930,7 +4341,10 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
assert "pullConversation.append(comment)" in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
assert 'stackchain-feature-pull-workflow' in html
assert "pullWorkflowFeatures.run('pull-workflow'" in html
assert "let pullController = null" in html
assert "let reviewController = null" in html
assert "pullController.load(item)" in html
assert "createPullSheet.renderFile" in html
assert "pullController.toggleReviewed" in html
@@ -4050,6 +4464,14 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert '.create-issue-label-option' in html and 'min-height:44px' in html
def test_new_issue_capture_waits_for_retryable_feature_before_opening():
dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source
assert "if (!issueCapture)" in dashboard_source
assert "async function openCreateIssueSheet" in dashboard_source
@pytest.mark.anyio
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
html = await dashboard()
@@ -4195,18 +4617,26 @@ async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
@pytest.mark.anyio
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
html = await dashboard()
assert 'id="bulk-mark-read"' in html
assert 'id="bulk-mark-read-bar"' in html
assert 'class="my-work-bulk"' in html
assert 'id="select-updates"' in html
assert 'id="cancel-update-selection"' in html
assert 'id="update-selection-status"' in html
assert 'aria-live="polite"' in html
assert 'class="update-selector"' in html
assert 'type="checkbox"' in html
assert 'Select update ' in html
assert '.update-selector { min-height:44px;' in html
assert '.my-work-bulk { position:sticky;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
assert "'Confirm marking ' + bulkLabel + ' read'" in html
assert "const ids = allIds.slice(0, 50)" in html
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
assert "'Mark ' + selection.count + ' selected read'" in html
assert "'Confirm marking ' + selection.count + ' selected read'" in html
assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
assert 'notificationSelection.retain(result.failed)' in html
assert "notificationSelection.cancel()" in html
assert "createBulkNotificationAcknowledger" in html
assert "api/v1/notifications/read" in html
assert "body: JSON.stringify({ ids })" in html
@@ -4223,7 +4653,8 @@ async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile(
assert "api/v1/notifications?page=" in html
assert "snapshot.notification_pagination" in html
assert "notificationPager.loadMore(lastNotifications)" in html
assert "Math.min(lastNotifications.length, 50)" in html
assert "createNotificationSelection({" in html
assert "limit: 50" in html
@pytest.mark.anyio
@@ -4271,6 +4702,28 @@ controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
}
def test_review_controller_refreshes_checks_through_status_only_path():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
const calls = [];
const controller = createReviewController({{ fetchJson: async (url, options) => {{
calls.push({{url, accept:options.headers.Accept}});
return {{head_sha:'abc123', ci_state:'success', checks:[]}};
}} }});
controller.loadChecks({{repository:'stackchain/api', number:7}}).then(status =>
process.stdout.write(JSON.stringify({{calls, status}}))
);
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/pulls/7/review/checks",
"accept": "application/json",
}]
assert output["status"]["ci_state"] == "success"
def test_review_diff_rows_escape_content_and_toggle_accessibly():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -4311,6 +4764,53 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert output["hidden"] is False
def test_review_checks_render_blockers_first_with_safe_touch_links():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const result = reviewSheet.renderChecks([
{{name:'build', state:'success', description:'Passed'}},
{{name:'lint <mobile>', state:'failure', description:'Open & fix', url:'https://forge.example/jobs/4'}},
{{name:'release', state:'pending', description:'Waiting'}},
], escapeHtml, {{offline:false}});
const offline = reviewSheet.renderChecks([
{{name:'lint', state:'success', description:'Passed'}},
], escapeHtml, {{offline:true}});
process.stdout.write(JSON.stringify({{result, offline}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["result"]["summary"] == "1 failed · 1 pending · 1 passed"
assert output["result"]["expanded"] is True
assert output["result"]["html"].index("lint &lt;mobile&gt;") < output["result"]["html"].index("build")
assert 'href="https://forge.example/jobs/4"' in output["result"]["html"]
assert 'target="_blank" rel="noopener noreferrer"' in output["result"]["html"]
assert "Open job" in output["result"]["html"]
assert output["offline"]["summary"].startswith("Last known · ")
@pytest.mark.anyio
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
html = await dashboard()
assert html.index('id="pull-checks"') < html.index('id="pull-files"')
assert html.index('id="review-checks"') < html.index('id="review-files"')
assert 'id="refresh-pull-checks"' in html
assert 'id="refresh-review-checks"' in html
assert "createReviewController.renderChecks" in html
assert "pullController.loadChecks(item)" in html
assert "reviewController.loadChecks(item)" in html
assert "New commits detected. Reload review data before merging." in html
assert "New commits detected. Reload the review before submitting feedback." in html
assert "qs('#refresh-review-checks').disabled = offlineReview" in html
assert ".ci-check-link" in html and "min-height:44px" in html
assert ".ci-check-copy" in html and "overflow-wrap:anywhere" in html
def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -4618,6 +5118,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
View File
@@ -66,6 +66,7 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
"assignees": [],
"claimable": True,
}
assert result["acknowledge_supported"] is True
@pytest.mark.anyio
+138
View File
@@ -41,6 +41,117 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
async def fake_fetch(path):
calls.append(("fetch", path))
if path == "notifications/threads/42":
return {
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": (
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
),
},
}
if path == "user":
return {"login": "timmy"}
raise AssertionError(path)
class Response:
def __init__(self, payload=None):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class Client:
async def get(self, path, headers):
calls.append(("get", path))
return Response([])
async def post(self, path, headers, json):
calls.append(("post", path, json))
return Response({"content": "+1"})
async def patch(self, path, headers):
calls.append(("patch", path))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.acknowledge_notification(42)
reaction_path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
assert result == {"id": 42, "reaction": "created", "status": "read"}
assert calls == [
("fetch", "notifications/threads/42"),
("fetch", "user"),
("get", reaction_path),
("post", reaction_path, {"content": "+1"}),
("patch", "/api/v1/notifications/threads/42?to-status=read"),
]
@pytest.mark.anyio
async def test_acknowledge_notification_reuses_existing_operator_reaction_on_retry(monkeypatch):
posts = []
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull",
"url": "https://forge.example/api/v1/repos/stackchain/api/pulls/7",
"latest_comment_url": (
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
),
},
}
class Response:
def __init__(self, payload=None):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class Client:
async def get(self, path, headers):
return Response([{"content": "+1", "user": {"login": "timmy"}}])
async def post(self, path, headers, json):
posts.append((path, json))
return Response()
async def patch(self, path, headers):
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
result = await gitea_proxy.acknowledge_notification(42)
assert result == {"id": 42, "reaction": "existing", "status": "read"}
assert posts == []
@pytest.mark.anyio
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
marked = []
@@ -61,6 +172,33 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
async def acknowledge(thread_id):
calls.append(thread_id)
return {"id": thread_id, "reaction": "created", "status": "read"}
monkeypatch.setattr(main.gitea_proxy, "acknowledge_notification", acknowledge, raising=False)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/notifications/42/acknowledge")
invalid = await client.post("/api/v1/notifications/0/acknowledge")
assert response.status_code == 200
assert response.json() == {"id": 42, "reaction": "created", "status": "read"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert calls == [42]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
async def mark(_thread_id):
+78
View File
@@ -0,0 +1,78 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js"
def run_node(script: str):
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_offline_blocker_reports_queued_only_after_durable_admission():
script = f"""
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
const events=[];
const queue=createOfflineIssueBlocker({{
createOperationId:()=> 'blocker-op',
enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}},
onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]),
}});
(async()=>{{
const result=await queue(
{{repository:'stackchain/dashboard',number:17}},
{{repository:'stackchain/api',number:9,title:'Restore API'}},
false
);
process.stdout.write(JSON.stringify({{events,result}}));
}})();
"""
output = run_node(script)
assert output["events"] == [
["admit", {
"kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17,
"blockerRepository": "stackchain/api", "blockerNumber": 9,
"present": False, "operationId": "blocker-op",
}],
["queued", 17, 9, False, "background"],
]
assert output["result"]["admission"]["durability"] == "background"
def test_offline_blocker_does_not_report_queued_when_durable_admission_fails():
script = f"""
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
let queued=false;
const queue=createOfflineIssueBlocker({{
enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}},
onQueued:()=>queued=true,
}});
queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true)
.then(()=>process.stdout.write(JSON.stringify({{queued}})))
.catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}})));
"""
output = run_node(script)
assert output == {"queued": False, "error": "IndexedDB unavailable"}
@pytest.mark.anyio
async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet():
html = await dashboard()
dashboard_js = (MODULE.parent / "dashboard.js").read_text()
service_worker = (MODULE.parent / "service-worker.js").read_text()
assert '<script src="static/offline-issue-blocker.js"></script>' in html
assert "BASE + 'static/offline-issue-blocker.js'" in service_worker
assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js
assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js
assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js
assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js
assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js
+27
View File
@@ -56,6 +56,33 @@ process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
def test_readiness_waits_for_durable_async_admission_and_reports_rejection():
result = run_scenario("""
const events = [];
const warmer = createOfflineToday({
loadDetail: async () => ({title:'Fetched detail'}),
loadSavedDetail: async () => null,
saveDetail: async () => {
events.push('admission-started');
await new Promise(resolve => setTimeout(resolve, 5));
events.push('admission-rejected');
return false;
},
onStatus: status => events.push('ready:' + status.ready + '/failed:' + status.failed),
});
const status = await warmer.warm('timmy', [{
kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v1',
}]);
process.stdout.write(JSON.stringify({events, status, failed:warmer.failedKeys()}));
""")
assert result["status"] == {"total": 1, "ready": 0, "failed": 1, "pending": 0}
assert result["failed"] == ["issue:stackchain/dashboard:1"]
assert result["events"].index("admission-rejected") < result["events"].index(
"ready:0/failed:1"
)
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
result = run_scenario("""
const items = [
+232 -1
View File
@@ -27,6 +27,221 @@ const storage = {{
return json.loads(result.stdout)
def run_async_scenario(scenario: str) -> dict:
script = f"""
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
const values = new Map();
const records = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const transaction = async work => work({{
get: async key => records.get(key),
getAll: async () => [...records.values()],
put: async value => records.set(value.id, structuredClone(value)),
delete: async key => records.delete(key),
clear: async () => records.clear(),
}});
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_indexeddb_commit_persists_snapshot_and_details_without_localstorage_payloads():
result = run_async_scenario("""
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const snapshotSaved = await store.save({
user:{login:'timmy', full_name:'Timmy'}, issues:[{number:8, title:'Large offline review'}],
pull_requests:[], notifications:[],
});
const item = {kind:'pull', repository:'stackchain/dashboard', number:8, is_review:true};
const detailSaved = await store.saveDetail('timmy', item, {
title:'Large offline review', head_sha:'abc123',
files:[{filename:'large.diff', diff_lines:['+private review line']}],
});
const restarted = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
process.stdout.write(JSON.stringify({
snapshotSaved, detailSaved,
loaded:await restarted.load('timmy'),
detail:await restarted.loadDetail('timmy', item),
localValues:[...values.values()].join(' '),
recordIds:[...records.keys()].sort(),
}));
""")
assert result["snapshotSaved"] is True
assert result["detailSaved"] is True
assert result["loaded"]["user"]["login"] == "timmy"
assert result["detail"]["head_sha"] == "abc123"
assert "Large offline review" not in result["localValues"]
assert "private review line" not in result["localValues"]
assert result["recordIds"] == ["detail:timmy:pull:stackchain/dashboard:8", "snapshot"]
def test_legacy_localstorage_payloads_migrate_only_after_indexeddb_commit():
result = run_async_scenario("""
values.set('stackchain.offline-work.enabled.v1', 'true');
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]},
}));
values.set('stackchain.offline-work.details.v1', JSON.stringify([{
key:'issue:stackchain/dashboard:9', user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{title:'Migrated issue'},
}]));
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
const snapshot = await store.load('timmy');
const detail = await store.loadDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:9});
process.stdout.write(JSON.stringify({
snapshot, detail,
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
legacyDetails:values.has('stackchain.offline-work.details.v1'),
recordIds:[...records.keys()].sort(),
}));
""")
assert result["snapshot"]["user"]["login"] == "timmy"
assert result["detail"]["title"] == "Migrated issue"
assert result["legacySnapshot"] is False
assert result["legacyDetails"] is False
assert result["recordIds"] == ["detail:timmy:issue:stackchain/dashboard:9", "snapshot"]
def test_failed_migration_keeps_legacy_snapshot_readable_and_untouched():
result = run_async_scenario("""
values.set('stackchain.offline-work.enabled.v1', 'true');
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{user:{login:'timmy'}, issues:[{number:7, title:'Keep me'}], pull_requests:[], notifications:[]},
}));
let attempts = 0;
const flakyTransaction = async work => {
attempts += 1;
if (attempts === 1) throw new Error('quota admission failed');
return transaction(work);
};
const store = createOfflineWorkStore({storage, transaction:flakyTransaction, now:() => new Date('2026-08-07T12:01:00Z')});
const snapshot = await store.load('timmy');
process.stdout.write(JSON.stringify({
snapshot, attempts,
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
recordIds:[...records.keys()],
}));
""")
assert result["snapshot"]["issues"][0]["title"] == "Keep me"
assert result["legacySnapshot"] is True
assert result["recordIds"] == []
def test_ready_atomically_prunes_all_expired_indexeddb_records():
result = run_async_scenario("""
const writer = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z'), maxAgeMs:1000});
writer.setEnabled(true);
await writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
await writer.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:1}, {title:'One'});
await writer.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:2}, {title:'Two'});
const expired = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:02Z'), maxAgeMs:1000});
await expired.ready();
process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
""")
assert result["recordIds"] == []
def test_ready_degrades_within_deadline_when_indexeddb_never_settles():
result = run_async_scenario("""
values.set('stackchain.offline-work.enabled.v1', 'true');
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
data:{user:{login:'timmy'}, issues:[{number:7, title:'Still available'}], pull_requests:[], notifications:[]},
}));
const neverTransaction = async () => new Promise(() => {});
const store = createOfflineWorkStore({
storage, transaction:neverTransaction, initializationTimeoutMs:20,
now:() => new Date('2026-08-07T12:01:00Z'),
});
const started = Date.now();
const outcome = await Promise.race([
store.ready().then(value => ({value, status:store.status(), saved:store.load('timmy')})),
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 100)),
]);
process.stdout.write(JSON.stringify({...outcome, elapsed:Date.now() - started,
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1')}));
""")
assert result.get("timedOut", False) is False
assert result["value"] is False
assert result["status"] == {"state": "degraded", "reason": "deadline"}
assert result["saved"]["issues"][0]["title"] == "Still available"
assert result["legacySnapshot"] is True
assert result["elapsed"] < 100
def test_degraded_store_rejects_saves_without_waiting_for_stalled_indexeddb():
result = run_async_scenario("""
values.set('stackchain.offline-work.enabled.v1', 'true');
const neverTransaction = async () => new Promise(() => {});
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
await store.ready();
const outcome = await Promise.race([
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]})
.then(value => ({value})),
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
]);
process.stdout.write(JSON.stringify(outcome));
""")
assert result == {"value": False}
def test_degraded_store_clears_legacy_data_without_waiting_for_indexeddb():
result = run_async_scenario("""
values.set('stackchain.offline-work.snapshot.v1', '{"private":"snapshot"}');
values.set('stackchain.offline-work.details.v1', '[{"private":"detail"}]');
const neverTransaction = async () => new Promise(() => {});
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
await store.ready();
const outcome = await Promise.race([
store.clear().then(value => ({value, keys:[...values.keys()]})),
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
]);
process.stdout.write(JSON.stringify(outcome));
""")
assert result == {"value": True, "keys": []}
def test_retry_recovers_durable_saving_after_initialization_deadline():
result = run_async_scenario("""
let stalled = true;
const recoveringTransaction = async work => stalled ? new Promise(() => {}) : transaction(work);
const store = createOfflineWorkStore({storage, transaction:recoveringTransaction, initializationTimeoutMs:10});
const initial = await store.ready();
stalled = false;
const recovered = await store.retry();
store.setEnabled(true);
const saved = await store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
process.stdout.write(JSON.stringify({initial, recovered, saved, status:store.status(), recordIds:[...records.keys()]}));
""")
assert result == {
"initial": False,
"recovered": True,
"saved": True,
"status": {"state": "ready", "reason": ""},
"recordIds": ["snapshot"],
}
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
@@ -213,11 +428,27 @@ async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydr
assert '.offline-work-controls button { min-height:44px;' in html
@pytest.mark.anyio
async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
html = await dashboard()
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
assert "let offlineStorageReady = await offlineWorkStore.ready();" in html
assert 'id="retry-offline-storage"' in html
assert "Offline saving unavailable · online work remains live." in html
assert "await offlineWorkStore.retry()" in html
assert "async function hydrateOfflineWork" in html
assert "const saved = await offlineWorkStore.load();" in html
assert "await offlineWorkStore.save({" in html
assert "await offlineWorkStore.clear();" in html
assert "Offline saving unavailable · retry after reconnect." in html
@pytest.mark.anyio
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
html = await dashboard()
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
assert "if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;" in html
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
+106
View File
@@ -0,0 +1,106 @@
import sqlite3
from src.passkey_store import PasskeyStore
def test_active_challenges_are_bounded_per_source_and_globally_across_instances(tmp_path):
now = [1_000.0]
database = tmp_path / "passkeys.sqlite3"
first = PasskeyStore(
database,
clock=lambda: now[0],
max_challenges=3,
max_challenges_per_source=2,
)
second = PasskeyStore(
database,
clock=lambda: now[0],
max_challenges=3,
max_challenges_per_source=2,
)
for challenge in (b"first", b"second", b"third"):
first.issue_challenge(
challenge,
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
source="203.0.113.7",
)
second.issue_challenge(
b"other-one",
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
source="203.0.113.8",
)
second.issue_challenge(
b"other-two",
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
source="203.0.113.8",
)
with sqlite3.connect(database) as connection:
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (3,)
counts = connection.execute(
"SELECT source_hash, COUNT(*) FROM passkey_challenges GROUP BY source_hash"
).fetchall()
assert sorted(count for _source, count in counts) == [1, 2]
now[0] = 1_121.0
first.issue_challenge(
b"after-expiry",
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
source="203.0.113.9",
)
with sqlite3.connect(database) as connection:
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(tmp_path):
database = tmp_path / "passkeys.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"""
CREATE TABLE passkey_challenges (
challenge_hash TEXT PRIMARY KEY,
session_hash TEXT,
purpose TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
connection.execute(
"INSERT INTO passkey_challenges VALUES ('existing', NULL, "
"'authentication', 'sign_in', 'dashboard', 1120)"
)
store = PasskeyStore(database, clock=lambda: 1_000.0)
store.issue_challenge(
b"new",
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
source="203.0.113.7",
)
with sqlite3.connect(database) as connection:
columns = {
row[1] for row in connection.execute("PRAGMA table_info(passkey_challenges)")
}
rows = connection.execute(
"SELECT challenge_hash FROM passkey_challenges ORDER BY challenge_hash"
).fetchall()
assert "source_hash" in columns
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
+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-v87" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+4 -1
View File
@@ -43,7 +43,10 @@ const clear = createPrivateDeviceDataPurger({{
state = json.loads(result.stdout)
assert state["remaining"] == ["other.preference"]
assert state["databases"] == ["stackchain-background-outbox-v1"]
assert state["databases"] == [
"stackchain-background-outbox-v1",
"stackchain-offline-work-v2",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["complete"] is True
+63
View File
@@ -125,6 +125,69 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
]
@pytest.mark.anyio
async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"state": "open", "draft": False, "mergeable": True, "merged": False,
"head": {"sha": "abc123"},
})
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={
"state": "success",
"statuses": [{"context": "tests", "status": "success", "description": "Passed"}],
})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
status = await gitea_proxy.pull_check_status("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
]
assert status == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success",
"checks": [{"name": "tests", "state": "success", "description": "Passed", "url": ""}],
}
@pytest.mark.anyio
async def test_assigned_pull_checks_endpoint_authorizes_and_returns_status_only(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def checks(repository, number):
calls.append(("checks", repository, number))
return {"head_sha": "abc123", "ci_state": "pending", "checks": []}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_check_status", checks)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/checks")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"head_sha": "abc123", "ci_state": "pending", "checks": []}
assert calls == [
("assigned", "stackchain/api", 7),
("checks", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
async def handler(request):
+10 -1
View File
@@ -116,12 +116,21 @@ 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
def test_pull_screenshot_upload_uses_binary_attachment_boundary():
assert (
main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/pulls/31/attachments"
)
== 2 * 1024 * 1024 + 64 * 1024
)
@pytest.mark.anyio
async def test_request_limit_uses_application_path_under_domain_subpath():
downstream_called = False
+78
View File
@@ -36,6 +36,84 @@ async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkey
assert response.json()["files"][0]["filename"] == "src/api.py"
@pytest.mark.anyio
async def test_requested_review_checks_endpoint_authorizes_and_returns_status_only(monkeypatch):
calls = []
async def requested(repository, number):
calls.append(("requested", repository, number))
return True
async def checks(repository, number):
calls.append(("checks", repository, number))
return {"head_sha": "def456", "ci_state": "success", "checks": []}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "pull_check_status", checks)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review/checks")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"head_sha": "def456", "ci_state": "success", "checks": []}
assert calls == [
("requested", "stackchain/api", 7),
("checks", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git")
async def handler(request):
path = request.url.path
if path.endswith("/pulls/7"):
return httpx.Response(200, json={
"title": "Review API", "head": {"sha": "abc123"},
"user": {"login": "alex"},
})
if path.endswith("/pulls/7/files") or path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[])
if path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={
"state": "failure",
"statuses": [
{
"context": "lint/<unsafe>", "status": "failure",
"description": "Formatting failed " + "x" * 300,
"target_url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
},
{
"context": "external", "status": "pending",
"description": "Waiting", "target_url": "https://evil.example/job/1",
},
{"context": "build-release", "status": "success", "description": "Passed"},
] + [{"context": f"extra-{index}", "status": "success"} for index in range(30)],
})
if path.endswith("/pulls/7.diff"):
return httpx.Response(200, text="")
raise AssertionError(f"unexpected request: {request.method} {path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert len(detail["checks"]) == 20
assert detail["checks"][:3] == [
{
"name": "lint/<unsafe>", "state": "failure",
"description": "Formatting failed " + "x" * 222,
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
},
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
]
@pytest.mark.anyio
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
async def requested(repository, number):
+774
View File
@@ -0,0 +1,774 @@
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",
}
],
"authentication_alerts": [],
"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_passkey_enrollment_reservation_failure_preserves_registry(
security_access, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
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": "enroll_passkey",
"target": "current_device",
},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
enrolled = await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
assert enrolled.status_code == 503
assert enrolled.json() == {"detail": "Security activity is temporarily unavailable"}
assert main._passkey_store().all() == []
@pytest.mark.anyio
async def test_passkey_enrollment_registry_failure_discards_reserved_event(
security_access, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
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": "enroll_passkey",
"target": "current_device",
},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
store = main._passkey_store()
def fail_registration(**_kwargs):
raise main.dashboard_auth.SessionStoreError("unavailable")
monkeypatch.setattr(store, "register", fail_registration)
monkeypatch.setattr(main, "_passkey_store", lambda: store)
enrolled = await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
events = SecurityEventStore(
security_access / "security.sqlite3", clock=lambda: 0
).list(limit=10).events
assert enrolled.status_code == 503
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
assert all(event.kind != "passkey_enrolled" for event in events)
@pytest.mark.anyio
async def test_passkey_enrollment_finalization_failure_retains_pending_event(
security_access, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
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": "enroll_passkey",
"target": "current_device",
},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
journal = main._security_event_store()
def fail_finalization(_operation_id):
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(journal, "finalize", fail_finalization)
monkeypatch.setattr(main, "_security_event_store", lambda: journal)
enrolled = await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
activity = await client.get("/api/v1/security-events")
event = activity.json()["events"][0]
assert enrolled.status_code == 201
assert enrolled.json() == {"enrolled": True}
assert event["kind"] == "passkey_enrolled"
assert event["status"] == "pending"
assert event["device_label"] == "Phone"
assert event["method"] == "passkey"
assert event["target"] == "passkey"
@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 == []
+57 -12
View File
@@ -17,7 +17,10 @@ const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
new Response(JSON.stringify({{
expires_at: Math.floor(Date.now() / 1000) + 3600,
idle_expires_at: Math.floor(Date.now() / 1000) + 900,
}})),
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
@@ -122,7 +125,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-v87" 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 +134,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-v87" 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-v87" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +149,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-v87" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +157,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-v87" 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-v87" 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 +173,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-v87" 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-v87" 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-v87" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -328,6 +331,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 +357,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 +397,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-v87" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -407,6 +419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/feature-loader.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
@@ -417,6 +430,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-issue-close.js",
"/dashboard/static/offline-issue-blocker.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
@@ -426,6 +440,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",
@@ -493,7 +508,7 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1, idleExpiresAt:1});
state.failFetch = true;
state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', {
@@ -514,6 +529,36 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
assert "private cached dashboard" not in result["body"]
def test_expired_idle_lease_locks_cached_shell_without_purging_recoverable_work():
result = run_worker_scenario(
"""
state.clientList = [{postMessage: message => { state.clientMessage = message; }}];
await dispatchMessage({
type:'stackchain-session-lease',
expiresAt:Math.floor(Date.now() / 1000) + 3600,
idleExpiresAt:1,
});
state.failFetch = true;
state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', {
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({
state, status:response.status, body:await response.text(),
cacheControl:response.headers.get('Cache-Control'),
}));
"""
)
assert result["status"] == 401
assert result["body"] == "Your Stackchain session is locked. Reconnect and sign in."
assert result["cacheControl"] == "no-store"
assert result["state"]["outboxPurges"] == 0
assert result["state"]["deleted"] == []
assert result["state"]["clientMessage"] == {"type": "stackchain-session-idle"}
assert "private cached dashboard" not in result["body"]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
+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-v87';" 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-v87" in source
assert "BASE + 'static/today-sync.js'" in source
+178
View File
@@ -0,0 +1,178 @@
import json
import subprocess
from pathlib import Path
import httpx
import pytest
from src import gitea_proxy, main
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).parents[1]
OUTBOX = ROOT / "frontend" / "authored-outbox.js"
SYNC = ROOT / "frontend" / "background-issue-sync.js"
PNG = b"\x89PNG\r\n\x1a\nmobile-update"
def run_node(script: str):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
@pytest.fixture(autouse=True)
def clear_idempotency():
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
yield
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
@pytest.mark.anyio
async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(monkeypatch):
calls = []
async def upload(thread_id, filename, content_type, content):
calls.append((thread_id, filename, content_type, content))
return {"name": filename, "url": "https://forge.example/a/proof.webp", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications/527/attachments",
files={"file": ("proof.png", PNG, "image/png")},
headers={"Idempotency-Key": "reply-527:attachment"},
)
assert response.status_code == 201
assert response.json()["markdown"] == "![proof.png](<https://forge.example/a/proof.webp>)"
assert calls == [(527, "proof.png", "image/png", PNG)]
assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024
@pytest.mark.anyio
async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET":
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/web"},
"subject": {
"type": "Pull",
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/web/pulls/31",
},
})
return httpx.Response(201, json={
"name": "proof.png", "size": len(PNG),
"browser_download_url": "https://forge.example/a/proof.png",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.upload_notification_attachment(527, "proof.png", "image/png", PNG)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/notifications/threads/527"),
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
]
assert result["url"] == "https://forge.example/a/proof.png"
@pytest.mark.anyio
async def test_update_reply_composer_offers_mobile_safe_removable_screenshot_preview():
html = await dashboard()
assert 'id="update-reply-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'id="update-reply-attachment-preview"' in html
assert 'id="remove-update-reply-attachment"' in html
assert "const updateReplyAttachmentController = issueAttachment.mount({" in html
assert "await updateReplyAttachmentController.serialize()" in html
assert ".update-reply .issue-attachment-preview { width:100%; min-width:0; }" in html
assert "@media (max-width:390px)" in html
def test_authored_outbox_durably_keeps_account_bound_update_screenshot_out_of_localstorage():
script = f"""
const createOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const mirrored=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}},
}}}});
(async()=>{{const result=await outbox.enqueueDurably({{
kind:'update-reply-read',notificationId:527,body:'',operationId:'reply-image',
attachment:{{filename:'phone.png',contentType:'image/png',blob:new Blob(['private-bytes'],{{type:'image/png'}})}},
}});process.stdout.write(JSON.stringify({{
result,local:outbox.list()[0],raw:values.get('stackchain.authored-outbox.v1'),
durable:{{ownerLogin:mirrored[0][0].ownerLogin,text:await mirrored[0][0].attachment.blob.text()}},
}}));}})();
"""
output = run_node(script)
assert output["local"]["attachment"] == {
"filename": "phone.png", "contentType": "image/png", "stored": True
}
assert output["durable"] == {"ownerLogin": "timmy", "text": "private-bytes"}
assert "private-bytes" not in output["raw"]
def test_update_screenshot_is_not_admitted_without_indexeddb_durability():
script = f"""
const createOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy'}});
(async()=>{{let error='';try{{await outbox.enqueueDurably({{
kind:'update-reply',notificationId:527,body:'proof',operationId:'no-db',
attachment:{{filename:'phone.png',contentType:'image/png',data:'private-bytes'}},
}});}}catch(caught){{error=caught.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list(),raw:values.get('stackchain.authored-outbox.v1')}}));}})();
"""
output = run_node(script)
assert output["error"] == "Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry."
assert output["items"] == []
assert "private-bytes" not in output["raw"]
def test_background_update_reply_screenshot_checkpoints_upload_then_reply_then_read():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let item={{id:'reply-image',operationId:'reply-image',ownerLogin:'timmy',status:'queued',
kind:'update-reply-read',notificationId:527,body:'',replyConfirmed:false,
attachment:{{filename:'phone.webp',contentType:'image/webp',blob:new Blob(['pixels'],{{type:'image/webp'}})}}}};
const calls=[];let replyAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}});
if(url.endsWith('/attachments'))return{{markdown:'![phone.webp](https://forge.example/phone.webp)'}};
if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return url.endsWith('/reply')?{{id:8}}:{{status:'read'}};
}};
(async()=>{{const sync=createSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
const checkpoint={{attachmentMarkdown:item.attachmentMarkdown,replyConfirmed:item.replyConfirmed}};
const result=await sync.flush();process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
"""
output = run_node(script)
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.webp]")
assert output["checkpoint"]["replyConfirmed"] is False
assert [call["url"] for call in output["calls"]] == [
"api/v1/notifications/527/attachments",
"api/v1/notifications/527/reply",
"api/v1/notifications/527/reply",
"api/v1/notifications/527/read",
]
assert [call["key"] for call in output["calls"][:3]] == [
"reply-image:attachment", "reply-image:reply", "reply-image:reply"
]
assert output["calls"][2]["body"] == {"body": "![phone.webp](https://forge.example/phone.webp)"}
assert output["result"]["confirmed"] == [{"status": "read"}]
+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"