Skip to content

Commit b0e77bd

Browse files
committed
improve UI, fix bearer token expiry
1 parent 897d242 commit b0e77bd

10 files changed

Lines changed: 135 additions & 43 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,20 @@ replaces them with GitHub's absolute counts. Counter responses use `no-cache`, s
9999
browsers revalidate with this service; that does not imply a GitHub request while
100100
the relevant snapshot remains fresh.
101101

102-
The browser runtime keeps the last counter snapshot in local storage for up to
103-
seven days. Consumers can render it synchronously while the API request is in
104-
flight, avoiding a flash of zero counters. Snapshots contain only the site/resource
105-
key, discussion node ID, counts, and save time; authentication tokens are not part
106-
of this cache. Storage is optional and failures fall back to the network normally.
102+
The browser runtime keeps the last counter snapshot and the last confirmed viewer
103+
vote state in local storage for up to seven days. Consumers can render them
104+
synchronously while the API request is in flight, avoiding a flash of zero counters
105+
or unselected vote buttons. Snapshots contain only the site/resource key, discussion
106+
node ID, counts, viewer state, and save time; authentication tokens are not part of
107+
this cache. Storage is optional and failures fall back to the network normally.
107108

108109
Operational logs are emitted at GitHub boundaries rather than for every HTTP
109110
request. Reaction-refresh lines include the site, trigger (`requested` or
110111
`sweep`), batch size, updated-row count, and duration. Failures include safe
111112
GitHub status/request IDs where available. Discussion discovery/creation, OAuth
112-
failures, vote failures, startup, and sweep summaries are also logged. Client
113-
IPs, origins, resource URLs, authorization codes, and tokens are not logged.
113+
failures, rejected creation grants, one-off GitHub App bearer retries, vote
114+
failures, startup, and sweep summaries are also logged. Client IPs, origins,
115+
resource URLs, authorization codes, and tokens are not logged.
114116

115117
## Local checks
116118

‎runtime/src/api/client.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import { validateResourceId } from "../feedback/resources.js";
22
import type { Resource } from "../feedback/resources.js";
3-
import type { Vote, VoteResult } from "../protocol/github.js";
3+
import type { ViewerVote, Vote, VoteResult } from "../protocol/github.js";
44

5-
export interface ReactionState {
5+
interface CounterState {
66
id: string | null;
77
up: number;
88
down: number;
99
age: number;
1010
stale: boolean;
1111
}
1212

13+
export interface ReactionState extends CounterState {
14+
viewer: ViewerVote;
15+
}
16+
1317
interface ReactionEnvelope {
1418
v: 1;
1519
site: string;
16-
items: Record<string, ReactionState>;
20+
items: Record<string, CounterState>;
1721
}
1822

1923
export interface ClientOptions {
@@ -86,9 +90,10 @@ export class FeedbackClient {
8690
const result = new Map<string, ReactionState>();
8791
for (const key of normalized) {
8892
const state = payload.items[key] ?? emptyState();
89-
if (!isReactionState(state)) throw new TypeError("Invalid feedback service response");
90-
result.set(key, state);
91-
this.#storeReaction(key, state);
93+
if (!isCounterState(state)) throw new TypeError("Invalid feedback service response");
94+
const combined = { ...state, viewer: this.#cachedReaction(key)?.viewer ?? "none" };
95+
result.set(key, combined);
96+
this.#storeReaction(key, combined);
9297
}
9398
return result;
9499
}
@@ -168,6 +173,7 @@ export class FeedbackClient {
168173
down: result.down,
169174
age: 0,
170175
stale: true,
176+
viewer: result.viewer,
171177
});
172178
return result;
173179
}
@@ -239,7 +245,7 @@ export class FeedbackError extends Error {
239245
}
240246

241247
function emptyState(): ReactionState {
242-
return { id: null, up: 0, down: 0, age: 0, stale: false };
248+
return { id: null, up: 0, down: 0, age: 0, stale: false, viewer: "none" };
243249
}
244250

245251
function isEnvelope(value: unknown, site: string): value is ReactionEnvelope {
@@ -248,7 +254,7 @@ function isEnvelope(value: unknown, site: string): value is ReactionEnvelope {
248254
return candidate.v === 1 && candidate.site === site && !!candidate.items && typeof candidate.items === "object";
249255
}
250256

251-
function isReactionState(value: unknown): value is ReactionState {
257+
function isCounterState(value: unknown): value is CounterState {
252258
if (!value || typeof value !== "object") return false;
253259
const state = value as Partial<ReactionState>;
254260
return (
@@ -260,6 +266,14 @@ function isReactionState(value: unknown): value is ReactionState {
260266
);
261267
}
262268

269+
function isReactionState(value: unknown): value is ReactionState {
270+
return isCounterState(value) && isViewerVote((value as Partial<ReactionState>).viewer);
271+
}
272+
273+
function isViewerVote(value: unknown): value is ViewerVote {
274+
return value === "up" || value === "down" || value === "both" || value === "none";
275+
}
276+
263277
function counterKey(site: string, resource: string): string {
264278
return `cppsocial.feedback.v1.${site}.reaction.${resource}`;
265279
}

‎runtime/src/example/page.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function renderCached(): void {
4141
const card = cards.get(key);
4242
if (!card) continue;
4343
card.discussionId = state.id;
44-
render(card, state.up, state.down);
44+
render(card, state.up, state.down, state.viewer);
4545
}
4646
status.textContent = "Showing saved counts while updating…";
4747
}
@@ -54,7 +54,7 @@ async function refresh(): Promise<void> {
5454
const state = states.get(key);
5555
if (!state) throw new Error(`Missing reaction state for ${key}`);
5656
card.discussionId = state.id;
57-
render(card, state.up, state.down);
57+
render(card, state.up, state.down, state.viewer);
5858
stale ||= state.stale;
5959
}
6060
status.textContent = stale ? "Some cached counts could not be refreshed." : "Ready.";

‎runtime/test/client.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,18 @@ void test("last counter values are available before the network responds", async
121121
apiOrigin: "https://feedback-api.cpp.social",
122122
site: "cpp-social",
123123
counterStorage: storage,
124-
fetch: () => Promise.resolve(Response.json(response)),
124+
fetch: (input) => Promise.resolve(Response.json(
125+
(input instanceof Request ? input.url : input.toString()).endsWith("/votes")
126+
? { v: 1, up: 15, down: 2, viewer: "up" }
127+
: response,
128+
)),
125129
});
126130
await first.reactions(["article"]);
131+
await first.vote(
132+
"article",
133+
"up",
134+
{ value: "ghu_user", expiresAt: 2_000_000_000, creationGrant: "grant" },
135+
);
127136
const reloaded = new FeedbackClient({
128137
apiOrigin: "https://feedback-api.cpp.social",
129138
site: "cpp-social",
@@ -135,7 +144,8 @@ void test("last counter values are available before the network responds", async
135144

136145
assert.ok(cached);
137146
assert.equal(cached.id, "D_article");
138-
assert.equal(cached.up, 14);
147+
assert.equal(cached.up, 15);
139148
assert.equal(cached.down, 2);
140149
assert.equal(cached.stale, true);
150+
assert.equal(cached.viewer, "up");
141151
});

‎src/feedback/api/routes.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,12 @@ async def ensure_discussion(request: Request) -> Response:
166166
discussion = await container.discussions.ensure(
167167
site, container.databases[site.id], body.resource
168168
)
169-
except GrantError:
169+
except GrantError as exc:
170+
oauth_logger.warning(
171+
"Discussion creation grant rejected: reason=%s site=%s",
172+
str(exc),
173+
site.id,
174+
)
170175
raise ApiError("invalid_creation_grant", "Authentication must be restarted.", 401) from None
171176
except DiscussionError as exc:
172177
raise ApiError("discussion_invalid", str(exc), 400) from exc

‎src/feedback/protocol/github/client.py‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import logging
45
import time
56
from collections.abc import Callable, Mapping
67
from dataclasses import dataclass
@@ -10,6 +11,8 @@
1011
import httpx
1112
import jwt
1213

14+
logger = logging.getLogger("feedback.github")
15+
1316

1417
class GitHubError(RuntimeError):
1518
def __init__(
@@ -97,15 +100,15 @@ async def graphql(
97100

98101
async def _request_installation_token(self, installation_id: int) -> InstallationToken:
99102
self._raise_if_rate_limited()
100-
async with self._requests:
101-
response = await self._http.post(
102-
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
103-
headers={
104-
"Accept": "application/vnd.github+json",
105-
"Authorization": f"Bearer {self.app_jwt()}",
106-
"X-GitHub-Api-Version": "2022-11-28",
107-
},
103+
response = await self._installation_token_request(installation_id)
104+
if response.status_code == 401:
105+
logger.warning(
106+
"GitHub App bearer token rejected; retrying once: status=401 "
107+
"github_request_id=%s installation_id=%s",
108+
response.headers.get("x-github-request-id"),
109+
installation_id,
108110
)
111+
response = await self._installation_token_request(installation_id)
109112
if response.status_code != 201:
110113
raise self._error(response, "installation_token_failed")
111114
self._rate_limit_failures = 0
@@ -120,6 +123,22 @@ async def _request_installation_token(self, installation_id: int) -> Installatio
120123
raise _response_error(response, "github_malformed_response") from exc
121124
return InstallationToken(value, expires_at)
122125

126+
async def _installation_token_request(self, installation_id: int) -> httpx.Response:
127+
async with self._requests:
128+
try:
129+
return await self._http.post(
130+
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
131+
headers={
132+
"Accept": "application/vnd.github+json",
133+
"Authorization": f"Bearer {self.app_jwt()}",
134+
"X-GitHub-Api-Version": "2022-11-28",
135+
},
136+
)
137+
except httpx.TimeoutException as exc:
138+
raise GitHubError("github_timeout") from exc
139+
except httpx.RequestError as exc:
140+
raise GitHubError("github_transport_error") from exc
141+
123142
async def _graphql_request(
124143
self, token: str, query: str, variables: Mapping[str, object]
125144
) -> httpx.Response:

‎src/feedback/service/oauth_state.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,13 @@ def __init__(
111111
self,
112112
key: bytes,
113113
*,
114-
lifetime_seconds: int = 300,
114+
lifetime_seconds: int = 8 * 60 * 60,
115115
clock: Callable[[], float] = time.time,
116116
) -> None:
117117
if len(key) < 32:
118118
raise ValueError("grant signing key must contain at least 32 bytes")
119-
if not 60 <= lifetime_seconds <= 600:
120-
raise ValueError("grant lifetime must be from 60 through 600 seconds")
119+
if not 60 <= lifetime_seconds <= 8 * 60 * 60:
120+
raise ValueError("grant lifetime must be from 60 through 28800 seconds")
121121
self._key = hmac.digest(key, b"discussion-creation-grant", "sha256")
122122
self._lifetime = lifetime_seconds
123123
self._clock = clock
@@ -168,7 +168,7 @@ def verify(self, token: str, *, site: str, origin: str) -> None:
168168
or not isinstance(value["exp"], int)
169169
or value["iat"] > now + 30
170170
or value["exp"] < now
171-
or value["exp"] - value["iat"] > 600
171+
or value["exp"] - value["iat"] > 8 * 60 * 60
172172
):
173173
raise GrantError("invalid creation grant")
174174

‎tests/test_discussions.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ async def test_ensure_rejects_unconfigured_canonical_origin(config: Config, tmp_
8282
)
8383

8484

85-
def test_ensure_endpoint_requires_an_origin_bound_grant(config: Config) -> None:
85+
def test_ensure_endpoint_requires_an_origin_bound_grant(
86+
config: Config, caplog: pytest.LogCaptureFixture
87+
) -> None:
8688
grants = CreationGrantSigner(b"k" * 32, clock=lambda: 1_000)
8789
app = create_app(
8890
config,
@@ -106,19 +108,23 @@ def test_ensure_endpoint_requires_an_origin_bound_grant(config: Config) -> None:
106108
"grant": grant,
107109
},
108110
)
109-
rejected = client.post(
110-
"/v1/sites/cpp-social/discussions/ensure",
111-
headers={"Origin": "https://cpp.social"},
112-
json={
113-
"key": "feedback/other",
114-
"url": "https://cpp.social/other/",
115-
"grant": grant[:-1] + ("A" if grant[-1] != "A" else "B"),
116-
},
117-
)
111+
with caplog.at_level("WARNING", logger="feedback.oauth"):
112+
rejected = client.post(
113+
"/v1/sites/cpp-social/discussions/ensure",
114+
headers={"Origin": "https://cpp.social"},
115+
json={
116+
"key": "feedback/other",
117+
"url": "https://cpp.social/other/",
118+
"grant": grant[:-1] + ("A" if grant[-1] != "A" else "B"),
119+
},
120+
)
118121

119122
assert response.status_code == 200
120123
assert response.json() == {"v": 1, "id": "D_example", "number": 7}
121124
assert rejected.status_code == 401
125+
assert "Discussion creation grant rejected" in caplog.text
126+
assert "site=cpp-social" in caplog.text
127+
assert grant not in caplog.text
122128

123129

124130
@pytest.mark.asyncio

‎tests/test_github.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,40 @@ def handler(request: httpx.Request) -> httpx.Response:
5656
assert requests == 1
5757

5858

59+
@pytest.mark.asyncio
60+
async def test_installation_token_retries_one_rejected_app_bearer(
61+
caplog: pytest.LogCaptureFixture,
62+
) -> None:
63+
requests = 0
64+
65+
def handler(request: httpx.Request) -> httpx.Response:
66+
nonlocal requests
67+
requests += 1
68+
if requests == 1:
69+
return httpx.Response(
70+
401,
71+
headers={"X-GitHub-Request-Id": "request-123"},
72+
json={"message": "A JSON web token could not be decoded"},
73+
)
74+
return httpx.Response(
75+
201,
76+
json={"token": "ghs_secret", "expires_at": "2030-01-01T00:00:00Z"},
77+
)
78+
79+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
80+
client = GitHubClient(
81+
app_id=123, private_key=pem_private_key(), http=http, clock=lambda: 1_000
82+
)
83+
with caplog.at_level("WARNING", logger="feedback.github"):
84+
token = await client.installation_token(7)
85+
86+
assert token == "ghs_secret"
87+
assert requests == 2
88+
assert "GitHub App bearer token rejected; retrying once" in caplog.text
89+
assert "github_request_id=request-123" in caplog.text
90+
assert "JSON web token" not in caplog.text
91+
92+
5993
@pytest.mark.asyncio
6094
async def test_graphql_refreshes_once_after_unauthorized() -> None:
6195
token_requests = 0

‎tests/test_security.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def test_signed_state_expires() -> None:
6868
signer.verify(token, site="cpp-social", origin="https://cpp.social", verifier=VERIFIER)
6969

7070

71-
def test_creation_grant_is_short_lived_and_origin_bound() -> None:
71+
def test_creation_grant_matches_login_lifetime_and_is_origin_bound() -> None:
7272
now = 1_000
7373
signer = CreationGrantSigner(b"k" * 32, clock=lambda: now)
7474
grant = signer.issue(site="cpp-social", origin="https://cpp.social", nonce=NONCE)
@@ -77,5 +77,7 @@ def test_creation_grant_is_short_lived_and_origin_bound() -> None:
7777
with pytest.raises(GrantError):
7878
signer.verify(grant, site="cpp-social", origin="https://other.example")
7979
now = 1_301
80+
signer.verify(grant, site="cpp-social", origin="https://cpp.social")
81+
now = 1_000 + 8 * 60 * 60 + 1
8082
with pytest.raises(GrantError):
8183
signer.verify(grant, site="cpp-social", origin="https://cpp.social")

0 commit comments

Comments
 (0)