ourpr-mcp-server 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -19
- package/build/api.js +66 -102
- package/build/index.js +627 -372
- package/build/safe.js +11 -63
- package/build/types.js +1 -7
- package/icon.png +0 -0
- package/manifest.json +11 -6
- package/npm-shrinkwrap.json +1903 -0
- package/package.json +8 -4
- package/server.json +3 -3
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# ourpr-mcp-server
|
|
2
2
|
|
|
3
3
|
An MCP server that lets an AI agent read **your own** running history from
|
|
4
|
-
[ourpr](https://ourpr.app)
|
|
5
|
-
whatever agent you already use.
|
|
4
|
+
[ourpr](https://ourpr.app), and write one thing back. Ask about your
|
|
5
|
+
training in plain language, in whatever agent you already use.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Every tool reads. One tool writes, and only that one: `ourpr_plan_week` puts
|
|
8
|
+
a plan on a day still ahead. It cannot log a run, edit history, or issue
|
|
9
|
+
another credential, and it needs a token made with the write scope.
|
|
8
10
|
|
|
9
11
|
## Why it exists
|
|
10
12
|
|
|
@@ -15,40 +17,105 @@ credential you issued to yourself and can revoke.
|
|
|
15
17
|
|
|
16
18
|
## Setup
|
|
17
19
|
|
|
18
|
-
**1. Make a token.** In ourpr, go to
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
**1. Make a token.** In ourpr, go to
|
|
21
|
+
**Profile → Settings → ourpr. mcp → New token**. Name it after the machine it
|
|
22
|
+
will live on, and choose **Write** if you want the agent to plan your week;
|
|
23
|
+
**Read** otherwise. Copy it — it is shown once and cannot be
|
|
24
|
+
recovered. A token lasts 90 days.
|
|
21
25
|
|
|
22
26
|
**2. Point a client at it.** The package runs from npm; nothing to clone.
|
|
23
27
|
|
|
28
|
+
**Keep the token out of any file inside a project.** A project's `.mcp.json`,
|
|
29
|
+
`.cursor/mcp.json` or `.vscode/mcp.json` is often committed, and a committed
|
|
30
|
+
token can be read by anyone who can read the repository. Every setup below
|
|
31
|
+
keeps the token in a user-level place or in your system's secret store.
|
|
32
|
+
|
|
33
|
+
**Pin the version.** Each setup names an exact version, so a new release never
|
|
34
|
+
runs with your token until you choose it. Change the number to update.
|
|
35
|
+
|
|
24
36
|
### Claude Code
|
|
25
37
|
|
|
38
|
+
Read the token without echoing it, so it stays out of your shell history, then
|
|
39
|
+
add the server for your user, not for a project:
|
|
40
|
+
|
|
26
41
|
```bash
|
|
27
|
-
|
|
42
|
+
printf 'Token: '; read -rs OURPR_TOKEN; echo
|
|
43
|
+
claude mcp add --scope user --env OURPR_TOKEN="$OURPR_TOKEN" --transport stdio \
|
|
44
|
+
ourpr -- npx -y ourpr-mcp-server@0.4.0
|
|
45
|
+
unset OURPR_TOKEN
|
|
28
46
|
```
|
|
29
47
|
|
|
48
|
+
Claude Code keeps it in `~/.claude.json`, which belongs to your user.
|
|
49
|
+
|
|
30
50
|
### Claude Desktop
|
|
31
51
|
|
|
32
52
|
Download `ourpr.mcpb` from the latest release and open it. Claude Desktop asks
|
|
33
|
-
for the token and
|
|
53
|
+
for the token and keeps it in your system's secret store. This is the
|
|
54
|
+
recommended way.
|
|
34
55
|
|
|
35
|
-
Or edit `claude_desktop_config.json
|
|
56
|
+
Or edit `claude_desktop_config.json`, which lives in your user folder, not in
|
|
57
|
+
a project. The token is then plain text in that file:
|
|
36
58
|
|
|
37
59
|
```json
|
|
38
60
|
{
|
|
39
61
|
"mcpServers": {
|
|
40
62
|
"ourpr": {
|
|
41
63
|
"command": "npx",
|
|
42
|
-
"args": ["-y", "ourpr-mcp-server"],
|
|
64
|
+
"args": ["-y", "ourpr-mcp-server@0.4.0"],
|
|
43
65
|
"env": { "OURPR_TOKEN": "ourpr_pat_..." }
|
|
44
66
|
}
|
|
45
67
|
}
|
|
46
68
|
}
|
|
47
69
|
```
|
|
48
70
|
|
|
49
|
-
###
|
|
71
|
+
### VS Code
|
|
72
|
+
|
|
73
|
+
Run **MCP: Open User Configuration** and add the server there. VS Code asks
|
|
74
|
+
for the token once, masks it, and keeps it in its secret store:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"inputs": [
|
|
79
|
+
{
|
|
80
|
+
"type": "promptString",
|
|
81
|
+
"id": "ourpr-token",
|
|
82
|
+
"description": "ourpr personal access token",
|
|
83
|
+
"password": true
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"servers": {
|
|
87
|
+
"ourpr": {
|
|
88
|
+
"type": "stdio",
|
|
89
|
+
"command": "npx",
|
|
90
|
+
"args": ["-y", "ourpr-mcp-server@0.4.0"],
|
|
91
|
+
"env": { "OURPR_TOKEN": "${input:ourpr-token}" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Cursor
|
|
98
|
+
|
|
99
|
+
Set `OURPR_TOKEN` in your shell profile or your system's secret manager, and
|
|
100
|
+
add the server to the global `~/.cursor/mcp.json`. The file holds only a
|
|
101
|
+
reference to the variable:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"mcpServers": {
|
|
106
|
+
"ourpr": {
|
|
107
|
+
"command": "npx",
|
|
108
|
+
"args": ["-y", "ourpr-mcp-server@0.4.0"],
|
|
109
|
+
"env": { "OURPR_TOKEN": "${env:OURPR_TOKEN}" }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### If a token leaks
|
|
50
116
|
|
|
51
|
-
|
|
117
|
+
Revoke it in **Profile → Settings → ourpr. mcp**, then make a new one.
|
|
118
|
+
Revocation is immediate.
|
|
52
119
|
|
|
53
120
|
### From source
|
|
54
121
|
|
|
@@ -58,6 +125,12 @@ cd ourpr-mcp-server
|
|
|
58
125
|
npm install && npm run build && npm test
|
|
59
126
|
```
|
|
60
127
|
|
|
128
|
+
## Protocol
|
|
129
|
+
|
|
130
|
+
The server speaks MCP `2026-07-28`, and it still answers a client that opens
|
|
131
|
+
with the older `initialize` handshake. It is built on the official TypeScript
|
|
132
|
+
SDK v2 (`@modelcontextprotocol/server`), and the tests run each version.
|
|
133
|
+
|
|
61
134
|
## Environment
|
|
62
135
|
|
|
63
136
|
| | |
|
|
@@ -132,6 +205,67 @@ done that resembles a race you are training for.
|
|
|
132
205
|
"What have I run that's like Boston — 26 miles, 800 feet of climb?"
|
|
133
206
|
```
|
|
134
207
|
|
|
208
|
+
### `ourpr_training_blocks`
|
|
209
|
+
|
|
210
|
+
Your goal race and its Block: race day, distance, goal time, the week of the
|
|
211
|
+
Block today falls in, and the miles for each week so far. A week that has not
|
|
212
|
+
begun shows no miles, not zero. Also the Blocks before your past races, each
|
|
213
|
+
with its result, its weeks and its peak week.
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
"What week of my Dallas block am I in, and how is my mileage building?"
|
|
217
|
+
"How does this block compare with the one before my last marathon?"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`past`: how many past Blocks, newest first. Three by default.
|
|
221
|
+
|
|
222
|
+
### `ourpr_list_races`
|
|
223
|
+
|
|
224
|
+
Every race ourpr finds in your history, tune-ups included, through the same
|
|
225
|
+
detector the app's Blocks use. Each row carries the run id for
|
|
226
|
+
`ourpr_get_run`. The answer also names the fastest result at 5K, 10K, half
|
|
227
|
+
marathon and marathon.
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
"What is my half marathon PR?"
|
|
231
|
+
"List every marathon I have run, with the times"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`distance`, `limit`.
|
|
235
|
+
|
|
236
|
+
### `ourpr_list_plans`
|
|
237
|
+
|
|
238
|
+
The plans already on your week between two dates, two weeks from today by
|
|
239
|
+
default: the day, the name, the miles or time, the tag, your note, whether a
|
|
240
|
+
run you logged fulfilled it, and whether ourpr create wrote it. An agent reads
|
|
241
|
+
this before `ourpr_plan_week`, so a new plan does not land on a day that
|
|
242
|
+
already holds one.
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
"What do I have planned for the next two weeks?"
|
|
246
|
+
"Did I do the runs I planned last week?"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`start_date`, `end_date` (YYYY-MM-DD).
|
|
250
|
+
|
|
251
|
+
### `ourpr_plan_week`
|
|
252
|
+
|
|
253
|
+
The one write. One planned run, or a week of them, onto days still ahead.
|
|
254
|
+
Each lands on the runner's week as a plan they can see, edit and remove; the
|
|
255
|
+
day sheet says it came from ourpr create. It never logs a run.
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
"Put a 6 mile easy run on Tuesday and 14 long on Saturday"
|
|
259
|
+
"Write me next week: three easy days, one workout, one long run"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`plans`, one to fourteen, each with `date` (YYYY-MM-DD, after today) and any
|
|
263
|
+
of `miles`, `minutes`, `name`, `note`, `tag` (easy, workout, race), `is_long`.
|
|
264
|
+
|
|
265
|
+
Needs a token made with the **Write** scope, and ourpr create on the account.
|
|
266
|
+
A **Read** token, or an account without create, is refused before anything is
|
|
267
|
+
written. Thirty plans a day.
|
|
268
|
+
|
|
135
269
|
## How the tool set was chosen
|
|
136
270
|
|
|
137
271
|
By an evaluation, not by listing the API.
|
|
@@ -166,15 +300,19 @@ numbers the agent would only reduce anyway.
|
|
|
166
300
|
|
|
167
301
|
## Security
|
|
168
302
|
|
|
169
|
-
The token grants read access to your own
|
|
170
|
-
|
|
171
|
-
|
|
303
|
+
The token grants read access to your own runs, your goal race and your plans.
|
|
304
|
+
On ourpr, every query a token makes is scoped to its owner by construction, and
|
|
305
|
+
a test refuses a token route that could reach past it. A token made with the
|
|
306
|
+
write scope, with ourpr create, may also put plans on your own week through
|
|
307
|
+
one route, and nothing else. No token can log a run, issue another token, revoke
|
|
308
|
+
your existing ones, or widen its own scope.
|
|
172
309
|
|
|
173
|
-
Each token may make 60 reads a minute
|
|
174
|
-
`RateLimit` and `Retry-After` headers, and
|
|
310
|
+
Each token may make 60 reads a minute, and a runner may write 30 plans a day.
|
|
311
|
+
ourpr answers 429 past either, with `RateLimit` and `Retry-After` headers, and
|
|
312
|
+
the tool says how long to wait.
|
|
175
313
|
|
|
176
|
-
Revoke any token at any time in **Settings →
|
|
177
|
-
immediate and permanent — a revoked token can never be restored.
|
|
314
|
+
Revoke any token at any time in **Profile → Settings → ourpr. mcp**.
|
|
315
|
+
Revocation is immediate and permanent — a revoked token can never be restored.
|
|
178
316
|
|
|
179
317
|
### Tool results are untrusted content
|
|
180
318
|
|
|
@@ -241,6 +379,18 @@ bundle's entry point; `npx @anthropic-ai/mcpb pack` builds `ourpr.mcpb` for
|
|
|
241
379
|
the release. `server.json` registers the package in the MCP Registry with
|
|
242
380
|
`mcp-publisher publish`.
|
|
243
381
|
|
|
382
|
+
Trusted publishing is the only way in. The package's npm settings require
|
|
383
|
+
two-factor authentication and disallow tokens, so no stolen npm token can
|
|
384
|
+
publish a version, and the OIDC workflow still can. `npm-shrinkwrap.json` ships
|
|
385
|
+
in the package, so every install resolves the same dependency tree that the
|
|
386
|
+
release tested. Run `npm install` and commit the shrinkwrap after any
|
|
387
|
+
dependency change.
|
|
388
|
+
|
|
389
|
+
A release changes the version in five places: `package.json`,
|
|
390
|
+
`npm-shrinkwrap.json` (through `npm install`), `manifest.json`, `server.json`
|
|
391
|
+
and the `McpServer` in `src/index.ts`. Every setup command in this README pins
|
|
392
|
+
the version, and so does `AGENT_VERSION` in ourpr's `lib/agent-connect.ts`.
|
|
393
|
+
|
|
244
394
|
## License
|
|
245
395
|
|
|
246
396
|
MIT
|
package/build/api.js
CHANGED
|
@@ -1,42 +1,12 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
// AUTHENTICATION IS AN ENVIRONMENT VARIABLE, NOT A TOOL PARAMETER, and that is
|
|
4
|
-
// a deliberate difference from revenuecat-charts-mcp, which takes `api_key` on
|
|
5
|
-
// every call. RevenueCat issues one key per project and a person may hold
|
|
6
|
-
// several, so asking per call is right there. An ourpr token belongs to ONE
|
|
7
|
-
// runner and never changes between calls, so a parameter would put a live
|
|
8
|
-
// credential into every tool call the agent makes — into its context, its
|
|
9
|
-
// transcript, and any log of either. The env var keeps it out of all three.
|
|
10
|
-
//
|
|
11
|
-
// NOTHING THIS SERVER PRINTS MAY CARRY A CONFIGURED VALUE. `OURPR_TOKEN` is
|
|
12
|
-
// never printed anywhere, and `OURPR_API_URL` is reduced to scheme and host
|
|
13
|
-
// before it reaches an error — see `safeOrigin`. An error message travels into
|
|
14
|
-
// the agent's context and its transcript, so printing there is publishing.
|
|
15
|
-
//
|
|
16
|
-
// NOTHING HERE MAY WRITE TO STDOUT. On a stdio server stdout IS the protocol
|
|
17
|
-
// stream, and one stray `console.log` corrupts it. Diagnostics go to stderr.
|
|
1
|
+
// The token is an environment variable, never a tool parameter, so no call carries it.
|
|
18
2
|
import { cell } from "./safe.js";
|
|
19
3
|
const TOKEN = process.env.OURPR_TOKEN ?? "";
|
|
20
4
|
const BASE = (process.env.OURPR_API_URL ?? "https://ourpr.onrender.com/api").replace(/\/$/, "");
|
|
21
|
-
// A
|
|
22
|
-
// refuse: localhost is how this server runs against a dev backend.
|
|
5
|
+
// A token over plain http travels in the clear; localhost is the dev backend.
|
|
23
6
|
if (BASE.startsWith("http://") && !/^http:\/\/(localhost|127\.0\.0\.1)[:/]/.test(BASE)) {
|
|
24
7
|
console.error("[ourpr-mcp-server] OURPR_API_URL is http, not https - the token is not encrypted in transit");
|
|
25
8
|
}
|
|
26
|
-
/**
|
|
27
|
-
* The configured host, with everything else removed.
|
|
28
|
-
*
|
|
29
|
-
* NO ENVIRONMENT VALUE IS ECHOED WHOLE. An error goes straight into the
|
|
30
|
-
* agent's context and its transcript, so anything printed there is published.
|
|
31
|
-
* `OURPR_API_URL` is usually just a hostname — and it is a URL, so it CAN
|
|
32
|
-
* carry `user:password@` before the host, which a self-hosted or staging
|
|
33
|
-
* instance behind basic auth plausibly would. Dropping userinfo, path, query
|
|
34
|
-
* and fragment leaves the one part that helps a person debug and none of the
|
|
35
|
-
* part that must not travel.
|
|
36
|
-
*
|
|
37
|
-
* An unparseable value yields a placeholder rather than the raw string,
|
|
38
|
-
* because "unparseable" is exactly the case where the string is unexpected.
|
|
39
|
-
*/
|
|
9
|
+
/** The configured host only; userinfo, path and query never reach an error. */
|
|
40
10
|
function safeOrigin() {
|
|
41
11
|
try {
|
|
42
12
|
const url = new URL(BASE);
|
|
@@ -46,19 +16,9 @@ function safeOrigin() {
|
|
|
46
16
|
return "the configured host";
|
|
47
17
|
}
|
|
48
18
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Why a request failed, WITHOUT the message.
|
|
51
|
-
*
|
|
52
|
-
* A Node fetch failure carries the full request URL in `cause`, and a timeout
|
|
53
|
-
* message can too. The code — ECONNREFUSED, ENOTFOUND, TimeoutError — says
|
|
54
|
-
* everything a person needs and carries nothing they did not choose to
|
|
55
|
-
* publish.
|
|
56
|
-
*/
|
|
19
|
+
/** The error code, never the message, which can carry the request URL. */
|
|
57
20
|
function reason(err) {
|
|
58
|
-
//
|
|
59
|
-
// `TypeError: fetch failed` with the useful code — ECONNREFUSED, ENOTFOUND —
|
|
60
|
-
// one or two levels down in `cause`. Reading only the top gave "TypeError",
|
|
61
|
-
// which tells a person nothing they can act on.
|
|
21
|
+
// Node puts the useful code one or two levels down in cause.
|
|
62
22
|
let node = err;
|
|
63
23
|
for (let depth = 0; node && depth < 4; depth++) {
|
|
64
24
|
const code = node.code;
|
|
@@ -66,10 +26,7 @@ function reason(err) {
|
|
|
66
26
|
return code;
|
|
67
27
|
node = node.cause;
|
|
68
28
|
}
|
|
69
|
-
//
|
|
70
|
-
// MESSAGE is the only useful thing. Passed on ONLY when it cannot be
|
|
71
|
-
// carrying configured values: an "@" means userinfo, and the raw base or the
|
|
72
|
-
// token must never appear. Fails closed to the error name.
|
|
29
|
+
// A message passes only when it cannot carry a configured value.
|
|
73
30
|
const deepest = deepestMessage(err);
|
|
74
31
|
const safe = deepest &&
|
|
75
32
|
!deepest.includes("@") &&
|
|
@@ -98,15 +55,18 @@ export class ApiError extends Error {
|
|
|
98
55
|
function guidance(status, detail, retryAfterS) {
|
|
99
56
|
if (status === 429) {
|
|
100
57
|
const wait = /^\d+$/.test(retryAfterS ?? "") ? `${retryAfterS} seconds` : "a minute";
|
|
101
|
-
return
|
|
58
|
+
return ("ourpr allows 60 reads a minute for each token and 30 plans a day. " +
|
|
59
|
+
`Wait ${wait}, then ask again.`);
|
|
102
60
|
}
|
|
103
61
|
if (status === 401) {
|
|
104
62
|
return ("ourpr rejected the token. It may be revoked, expired (a token lasts 90 " +
|
|
105
63
|
"days), or OURPR_TOKEN may be unset. Make a new one in ourpr under " +
|
|
106
|
-
"Settings,
|
|
64
|
+
"Profile, Settings, ourpr. mcp.");
|
|
65
|
+
}
|
|
66
|
+
if (status === 403) {
|
|
67
|
+
return ("ourpr refused the write. The token needs the write scope, chosen when " +
|
|
68
|
+
"it is made, and ourpr create behind it.");
|
|
107
69
|
}
|
|
108
|
-
if (status === 403)
|
|
109
|
-
return "That token can read only. This action needs a write scope.";
|
|
110
70
|
if (status === 404)
|
|
111
71
|
return "ourpr has no such run. Check the id against ourpr_list_runs.";
|
|
112
72
|
if (status === 422)
|
|
@@ -115,33 +75,27 @@ function guidance(status, detail, retryAfterS) {
|
|
|
115
75
|
return "ourpr had an error. Try again in a moment.";
|
|
116
76
|
return detail || `ourpr answered ${status}.`;
|
|
117
77
|
}
|
|
118
|
-
|
|
119
|
-
* One line per call, on stderr.
|
|
120
|
-
*
|
|
121
|
-
* The 2026 guidance asks a server to log tool invocations, downstream calls,
|
|
122
|
-
* errors and denials, so an operator can see what an agent did on their
|
|
123
|
-
* behalf. THE PATH IS LOGGED AND THE TOKEN NEVER IS — a log that carries the
|
|
124
|
-
* credential is a second copy of it, in a file nobody is guarding.
|
|
125
|
-
*/
|
|
78
|
+
// The path is logged and the token never is.
|
|
126
79
|
function audit(path, outcome, ms) {
|
|
127
80
|
console.error(`[ourpr-mcp-server] ${outcome} ${path.split("?")[0]} ${ms}ms`);
|
|
128
81
|
}
|
|
129
|
-
|
|
82
|
+
async function request(method, path, payload) {
|
|
130
83
|
const started = Date.now();
|
|
131
84
|
if (!TOKEN) {
|
|
132
|
-
throw new ApiError("OURPR_TOKEN is not set. Create a token in ourpr under
|
|
133
|
-
"
|
|
85
|
+
throw new ApiError("OURPR_TOKEN is not set. Create a token in ourpr under Profile, " +
|
|
86
|
+
"Settings, ourpr. mcp, then put it in this server's environment.");
|
|
134
87
|
}
|
|
135
88
|
let response;
|
|
136
89
|
try {
|
|
137
90
|
response = await fetch(`${BASE}${path}`, {
|
|
138
|
-
|
|
91
|
+
method,
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${TOKEN}`,
|
|
94
|
+
...(payload === undefined ? {} : { "Content-Type": "application/json" }),
|
|
95
|
+
},
|
|
96
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
139
97
|
signal: AbortSignal.timeout(60_000),
|
|
140
|
-
//
|
|
141
|
-
// Authorization header across origins, so this is belt rather than
|
|
142
|
-
// braces — but a redirect from ourpr's own host is not something this
|
|
143
|
-
// server should follow silently either, because the answer would then
|
|
144
|
-
// come from somewhere the operator did not configure.
|
|
98
|
+
// A request that carries the token never follows a redirect.
|
|
145
99
|
redirect: "manual",
|
|
146
100
|
});
|
|
147
101
|
}
|
|
@@ -149,10 +103,7 @@ export async function get(path) {
|
|
|
149
103
|
throw new ApiError(`Could not reach ourpr at ${safeOrigin()} (${reason(err)}). Check ` +
|
|
150
104
|
"OURPR_API_URL and the network.");
|
|
151
105
|
}
|
|
152
|
-
//
|
|
153
|
-
// network handler two lines up and re-reported as "could not reach ourpr" —
|
|
154
|
-
// an error that names the wrong cause is worse than the status code it
|
|
155
|
-
// replaced.
|
|
106
|
+
// Outside the try, so a redirect is not reported as a network failure.
|
|
156
107
|
if (response.status >= 300 && response.status < 400) {
|
|
157
108
|
audit(path, `${response.status}-redirect`, Date.now() - started);
|
|
158
109
|
throw new ApiError("ourpr redirected the request, and this server does not follow " +
|
|
@@ -162,13 +113,11 @@ export async function get(path) {
|
|
|
162
113
|
if (!response.ok) {
|
|
163
114
|
let detail = "";
|
|
164
115
|
try {
|
|
165
|
-
//
|
|
166
|
-
// backend, but a future backend change could derive it from runner
|
|
167
|
-
// text, and this is the one place it enters an error message.
|
|
116
|
+
// detail is remote text and is cleaned like any other.
|
|
168
117
|
detail = cell((await response.json()).detail, 200);
|
|
169
118
|
}
|
|
170
119
|
catch {
|
|
171
|
-
//
|
|
120
|
+
// The status carries the meaning without a body.
|
|
172
121
|
}
|
|
173
122
|
audit(path, `${response.status}`, Date.now() - started);
|
|
174
123
|
throw new ApiError(guidance(response.status, detail, response.headers.get("retry-after") ?? undefined));
|
|
@@ -178,9 +127,7 @@ export async function get(path) {
|
|
|
178
127
|
body = (await response.json());
|
|
179
128
|
}
|
|
180
129
|
catch {
|
|
181
|
-
// A
|
|
182
|
-
// parse error's message can carry a fragment of that body, so the error
|
|
183
|
-
// is replaced rather than passed on.
|
|
130
|
+
// A non-JSON body can leak into the parse error, so the error is replaced.
|
|
184
131
|
audit(path, "200-not-json", Date.now() - started);
|
|
185
132
|
throw new ApiError(`ourpr at ${safeOrigin()} answered 200 with a body that is not JSON. ` +
|
|
186
133
|
"Check OURPR_API_URL - it should be the API base, ending in /api.");
|
|
@@ -188,19 +135,11 @@ export async function get(path) {
|
|
|
188
135
|
audit(path, "200", Date.now() - started);
|
|
189
136
|
return body;
|
|
190
137
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* An id on its way into a URL path.
|
|
198
|
-
*
|
|
199
|
-
* Ids arrive from the agent, and an agent can be steered by text inside a
|
|
200
|
-
* tool result. Unencoded, a crafted id could append query parameters or move
|
|
201
|
-
* the request to a sibling path - fetch normalises "../" before sending.
|
|
202
|
-
* Encoding makes the id one path segment and nothing more.
|
|
203
|
-
*/
|
|
138
|
+
export const get = (path) => request("GET", path);
|
|
139
|
+
/** The one write. The route it reaches needs the write scope and ourpr create. */
|
|
140
|
+
export const post = (path, body) => request("POST", path, body);
|
|
141
|
+
// Units: the API speaks metres and an agent reads miles. Convert once, here.
|
|
142
|
+
/** Encoded, so a crafted id stays one path segment. */
|
|
204
143
|
export const pathId = (value) => encodeURIComponent(value);
|
|
205
144
|
export const METERS_PER_MILE = 1609.344;
|
|
206
145
|
export const miles = (meters) => meters == null ? null : Math.round((meters / METERS_PER_MILE) * 100) / 100;
|
|
@@ -213,16 +152,41 @@ export function duration(seconds) {
|
|
|
213
152
|
const m = Math.round((seconds % 3600) / 60);
|
|
214
153
|
return h ? `${h}h ${m}m` : `${m}m`;
|
|
215
154
|
}
|
|
155
|
+
/** "3:05:12" or "21:40". A race result reads to the second. */
|
|
156
|
+
export function clock(seconds) {
|
|
157
|
+
if (!seconds)
|
|
158
|
+
return null;
|
|
159
|
+
const whole = Math.round(seconds);
|
|
160
|
+
const h = Math.floor(whole / 3600);
|
|
161
|
+
const m = Math.floor((whole % 3600) / 60);
|
|
162
|
+
const s = String(whole % 60).padStart(2, "0");
|
|
163
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
164
|
+
}
|
|
216
165
|
/** A day key, YYYY-MM-DD, out of whatever the API stored. */
|
|
217
166
|
export const day = (iso) => (iso ?? "").slice(0, 10);
|
|
218
|
-
/**
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
167
|
+
/** Today on this machine's calendar, which is the runner's own. */
|
|
168
|
+
export function localDay(now) {
|
|
169
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
170
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
171
|
+
return `${now.getFullYear()}-${m}-${d}`;
|
|
172
|
+
}
|
|
173
|
+
// Day keys are walked as UTC dates, so a clock change never moves a day.
|
|
174
|
+
const DAY_MS = 86_400_000;
|
|
175
|
+
const dayMs = (key) => Date.parse(`${key}T00:00:00Z`);
|
|
176
|
+
export function addDays(key, days) {
|
|
177
|
+
const ms = dayMs(key);
|
|
178
|
+
if (Number.isNaN(ms))
|
|
179
|
+
throw new ApiError(`"${key}" is not a date. Use YYYY-MM-DD.`);
|
|
180
|
+
return new Date(ms + days * DAY_MS).toISOString().slice(0, 10);
|
|
181
|
+
}
|
|
182
|
+
/** Which week of a Block `today` falls in, or 0 before week 1's Monday. */
|
|
183
|
+
export function weekOf(start, weeks, today) {
|
|
184
|
+
const walked = Math.floor((dayMs(today) - dayMs(start)) / DAY_MS);
|
|
185
|
+
if (Number.isNaN(walked) || walked < 0)
|
|
186
|
+
return 0;
|
|
187
|
+
return Math.min(weeks, Math.floor(walked / 7) + 1);
|
|
188
|
+
}
|
|
189
|
+
/** A calendar date to a Unix second; "end" reaches the end of the day. */
|
|
226
190
|
export function stamp(date, edge) {
|
|
227
191
|
const time = edge === "start" ? "T00:00:00Z" : "T23:59:59Z";
|
|
228
192
|
const ms = Date.parse(`${date}${time}`);
|