diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
index d022b0a..b48c27f 100644
--- a/frontend/authored-outbox.js
+++ b/frontend/authored-outbox.js
@@ -315,6 +315,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
error: String(background.error || 'Message needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
+ if (background?.status === 'authorization') return [{
+ ...item,
+ status: 'authorization',
+ error: String(background.error || 'Fresh authorization required').slice(0, 240),
+ }];
return [item];
});
write(items);
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 9ea5c13..1f552fb 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -85,7 +85,8 @@ function createIssueSyncStore({
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);
}
@@ -153,7 +154,8 @@ 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 || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
@@ -243,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 =>
@@ -271,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);
@@ -560,6 +568,15 @@ function createBackgroundIssueSync({
await releaseClaim(item);
throw error;
}
+ if (status === 428 && item.kind === 'pull-review') {
+ const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
+ await requireAuthorization(item, message);
+ return {
+ authorization: true,
+ error,
+ receipt: receiptFor(item, 'authorization'),
+ };
+ }
if (status >= 400 && status < 500) {
await failClaim(
item,
@@ -590,11 +607,13 @@ function createBackgroundIssueSync({
const confirmed = [];
const receipts = [];
let attention = 0;
- if (!login) return { confirmed, blocked: 0, attention, login, receipts };
+ let authorization = 0;
+ if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
const collect = result => {
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
+ if (result.authorization) authorization += 1;
if (result.receipt) receipts.push(result.receipt);
};
if (store.planBatch) {
@@ -655,7 +674,7 @@ function createBackgroundIssueSync({
}
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
- return { confirmed, blocked, attention, login, receipts };
+ return { confirmed, blocked, attention, authorization, login, receipts };
}
function flush() {
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 7e7c887..a5a6f94 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1657,6 +1657,10 @@
'' +
'' +
'' :
+ reviewOutbox && item.status === 'authorization' ?
+ '' +
+ '' +
+ '' :
reviewOutbox && item.status === 'attention' ?
'' +
'' +
@@ -1778,11 +1782,17 @@
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id || !activeFlushLogin) return;
button.disabled = true;
- qs('#my-work-action-status').textContent = 'Fresh authorization required for this exact issue.';
+ const reviewAuthorization = item.outbox_kind === 'pull-review';
+ qs('#my-work-action-status').textContent = reviewAuthorization ?
+ 'Fresh authorization required for this exact review decision and head.' :
+ 'Fresh authorization required for this exact issue.';
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(result);
qs('#my-work-action-status').textContent = result.confirmed?.length ?
- 'Issue closed and queued intent cleared.' : 'Issue closure was not confirmed. The queued intent is still safe.';
+ (reviewAuthorization ? 'Review submitted and queued intent cleared.' :
+ 'Issue closed and queued intent cleared.') :
+ (reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
+ 'Issue closure was not confirmed. The queued intent is still safe.');
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
diff --git a/frontend/drafts.js b/frontend/drafts.js
index 75ec1f1..c62e9ba 100644
--- a/frontend/drafts.js
+++ b/frontend/drafts.js
@@ -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,
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index c22262d..c1f2d3b 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v84';
+const CACHE = 'stackchain-dashboard-shell-v85';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -205,11 +205,13 @@ async function flushAndNotify() {
!await issueSync.getReceiptPreference?.(result.login)) return;
for (const receipt of result.receipts) {
const needsAttention = receipt.status === 'attention';
- const title = needsAttention
+ const needsAuthorization = receipt.status === 'authorization';
+ const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
? 'Queued work needs attention'
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
await self.registration.showNotification(title, {
- body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
+ body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
+ needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
});
diff --git a/src/main.py b/src/main.py
index f293ffb..986abf2 100644
--- a/src/main.py
+++ b/src/main.py
@@ -204,6 +204,7 @@ class FreshAuthorization(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024)
action: Literal[
"merge_pull",
+ "submit_pull_review",
"close_issue",
"revoke_device",
"revoke_all_sessions",
@@ -3793,12 +3794,26 @@ async def merge_assigned_pull(
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
async def submit_review(
submission: PullReviewSubmission,
+ request: Request,
owner: str,
repo: str,
number: int,
idempotency_key: str | None = Header(default=None, max_length=128),
+ step_up_grant: str | None = Header(
+ default=None, alias="X-Step-Up-Grant", max_length=128
+ ),
):
repository = f"{owner}/{repo}"
+ if submission.decision != "comment":
+ await _require_step_up(
+ request,
+ step_up_grant,
+ action="submit_pull_review",
+ target=(
+ f"{repository}#{number}@{submission.expected_head_sha}:"
+ f"{submission.decision}"
+ ),
+ )
async def submit_requested_review():
if not await is_requested_review(repository, number):
diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py
index 3e2846b..3df1173 100644
--- a/tests/test_authored_outbox.py
+++ b/tests/test_authored_outbox.py
@@ -480,6 +480,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope
assert output["remaining"] == []
+def test_authorization_hold_can_resume_in_foreground_with_original_review_identity():
+ script = f"""
+const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+const values=new Map();const sent=[];
+const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const outbox=createAuthoredOutbox({{
+ storage,getOwnerLogin:()=>'timmy',
+ backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}},
+ send:async item=>{{sent.push({{operationId:item.operationId,status:item.status,
+ head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}},
+}});
+outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable',
+ body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}});
+outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization',
+ error:'Fresh authorization required'}}]);
+(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy');
+process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["held"]["status"] == "authorization"
+ assert output["sent"] == [{
+ "operationId": "review-stable", "status": "authorization",
+ "head": "abc123", "decision": "approve",
+ }]
+ assert output["result"]["confirmed"] == [{"id": 91}]
+ assert output["remaining"] == []
+
+
@pytest.mark.anyio
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
html = await dashboard()
diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py
index fd2ac8d..984b4b1 100644
--- a/tests/test_background_issue_sync.py
+++ b/tests/test_background_issue_sync.py
@@ -409,6 +409,41 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
}]
+def test_closed_app_sync_holds_consequential_review_for_foreground_authorization():
+ script = f"""
+const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued',
+ kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
+ decision:'approve',expectedHeadSha:'abc123',comments:[]}};
+const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false;
+const store={{
+ claimNext:async()=>claimed?null:(claimed=true,item),
+ authorization:async(id,error)=>state.authorized.push({{id,error}}),
+ fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id),
+ complete:async id=>state.completed.push(id),countBlocked:async()=>0,
+}};
+const fetchJson=async url=>{{
+ if(url==='api/v1/background-identity')return{{login:'timmy'}};
+ const error=new Error('Fresh authorization required');error.status=428;
+ error.code='step_up_required';throw error;
+}};
+(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
+process.stdout.write(JSON.stringify({{state,result}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["state"] == {
+ "authorized": [{"id": "review-op", "error": "Fresh authorization required"}],
+ "failed": [], "released": [], "completed": [],
+ }
+ assert output["result"]["attention"] == 0
+ assert output["result"]["authorization"] == 1
+ assert output["result"]["receipts"] == [{
+ "id": "review-op", "status": "authorization", "kind": "message",
+ "route": "#/my-work/review/stackchain/web/8",
+ }]
+
+
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
records = [
{
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 4c063bd..5770c1c 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
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-v84" in worker
+ assert "stackchain-dashboard-shell-v85" in worker
diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py
index 4049221..b9f37c9 100644
--- a/tests/test_dashboard_auth.py
+++ b/tests/test_dashboard_auth.py
@@ -953,6 +953,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)
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
index 8d8f744..85beef0 100644
--- a/tests/test_drafts.py
+++ b/tests/test_drafts.py
@@ -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))});
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 34c30f2..9422f8f 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -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-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 79e19ab..5ad880e 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -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-v84" in worker
+ assert "stackchain-dashboard-shell-v85" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 6b0a573..ff3896e 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -41,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-v84" in worker
+ assert "stackchain-dashboard-shell-v85" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 1d80025..075e22d 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -4800,6 +4800,9 @@ async def test_review_attention_drafts_require_revision_instead_of_resending_sta
assert "reviewOutbox && item.status === 'attention'" in html
assert ">Open current review" in html
assert ">Copy feedback" in html
+ assert "reviewOutbox && item.status === 'authorization'" in html
+ assert ">Authorize & send review" in html
+ assert "Review submitted and queued intent cleared." in html
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index e96e5e9..9b13000 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -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-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 47b311a..52d7055 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -328,6 +328,7 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
state.flushResult = {login:'timmy', receipts:[
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
+ {id:'review-1',status:'authorization',kind:'message',route:'#/my-work/drafts'},
]};
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
await dispatchSync('stackchain-issue-outbox-v1');
@@ -353,6 +354,14 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
"data": {"route": "#/my-work/drafts"},
},
},
+ {
+ "title": "Queued review needs authorization",
+ "options": {
+ "body": "Tap to authorize it in the Delivery center.",
+ "tag": "stackchain-delivery-review-1",
+ "data": {"route": "#/my-work/drafts"},
+ },
+ },
]
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
@@ -385,7 +394,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 5f2600f..4e6d9b5 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -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-v84';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v85';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index f11283d..bdb51ff 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -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-v84" in source
+ assert "stackchain-dashboard-shell-v85" in source
assert "BASE + 'static/today-sync.js'" in source