From 980467a1e724c31bbbd3cce41439b2af66ab0bc5 Mon Sep 17 00:00:00 2001 From: LeadGoEngineer Date: Mon, 11 May 2026 22:24:58 -0400 Subject: [PATCH] feat(phase3a): wire-capture tooling + redacted Cronometer fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUA-37 phase 3a: build the recording http.RoundTripper and capture-driver binary, run them against a real Cronometer account, and commit the redacted wire fixtures + WIRE_SHAPES.md / CAPTURE.md so the clean-room client (phase 3b) has a fixture-backed implementation target. What changed: - tools/wirecapture/ — separate Go module so importing GPL-2.0 gocronometer here does not contaminate the main module's license claim. Recording transport + capture binary are fresh-authored under MIT; no gocronometer source consulted (only its public API surface via go doc, plus this repo's own MIT call sites). - internal/cronoclient/testdata/cronometer/ — 14 redacted exchange metadata files (one per HTTP call in a full session: login + 5 generateAuthorizationToken calls + 5 /export GETs + logout). Cookie values, anti-CSRF tokens, per-session auth tokens, the password POST body, the username, and all real account data are stripped. Only wire metadata (URL, headers, status, body length) survives. - internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md — synthesized wire shapes for every endpoint, with placeholders for sensitive fields and explicit TODOs for behaviours not yet captured (long windows, auth failure shapes, 2FA). - internal/cronoclient/testdata/cronometer/CAPTURE.md — playbook for re-capturing when Cronometer rotates the GWT permutation hash or we need a previously-uncaptured failure mode. - .gitignore — block cronometer-capture/ and tools/wirecapture/captures/ so unredacted dumps never get committed. Phase 3b (the clean-room cronoapi package + go.mod swap) lands as a follow-up PR on this base. Refs: docs/cronometer-protocol.md (phase 1 spec) Co-Authored-By: Paperclip --- .gitignore | 9 + .../testdata/cronometer/001_login_get.json | 47 +++ .../testdata/cronometer/002_login_post.json | 58 ++++ .../cronometer/003_gwt_authenticate.json | 63 ++++ .../004_gwt_generate_auth_token_servings.json | 65 ++++ .../cronometer/005_export_servings.json | 61 ++++ ...006_gwt_generate_auth_token_exercises.json | 65 ++++ .../cronometer/007_export_exercises.json | 61 ++++ ...08_gwt_generate_auth_token_biometrics.json | 65 ++++ .../cronometer/009_export_biometrics.json | 61 ++++ ..._gwt_generate_auth_token_dailysummary.json | 65 ++++ .../cronometer/011_export_dailysummary.json | 61 ++++ .../012_gwt_generate_auth_token_notes.json | 65 ++++ .../testdata/cronometer/013_export_notes.json | 61 ++++ .../testdata/cronometer/014_gwt_logout.json | 69 ++++ .../testdata/cronometer/CAPTURE.md | 98 ++++++ .../testdata/cronometer/WIRE_SHAPES.md | 300 ++++++++++++++++++ tools/wirecapture/README.md | 42 +++ tools/wirecapture/base64.go | 5 + tools/wirecapture/go.mod | 10 + tools/wirecapture/go.sum | 4 + tools/wirecapture/main.go | 143 +++++++++ tools/wirecapture/recorder.go | 172 ++++++++++ 23 files changed, 1650 insertions(+) create mode 100644 internal/cronoclient/testdata/cronometer/001_login_get.json create mode 100644 internal/cronoclient/testdata/cronometer/002_login_post.json create mode 100644 internal/cronoclient/testdata/cronometer/003_gwt_authenticate.json create mode 100644 internal/cronoclient/testdata/cronometer/004_gwt_generate_auth_token_servings.json create mode 100644 internal/cronoclient/testdata/cronometer/005_export_servings.json create mode 100644 internal/cronoclient/testdata/cronometer/006_gwt_generate_auth_token_exercises.json create mode 100644 internal/cronoclient/testdata/cronometer/007_export_exercises.json create mode 100644 internal/cronoclient/testdata/cronometer/008_gwt_generate_auth_token_biometrics.json create mode 100644 internal/cronoclient/testdata/cronometer/009_export_biometrics.json create mode 100644 internal/cronoclient/testdata/cronometer/010_gwt_generate_auth_token_dailysummary.json create mode 100644 internal/cronoclient/testdata/cronometer/011_export_dailysummary.json create mode 100644 internal/cronoclient/testdata/cronometer/012_gwt_generate_auth_token_notes.json create mode 100644 internal/cronoclient/testdata/cronometer/013_export_notes.json create mode 100644 internal/cronoclient/testdata/cronometer/014_gwt_logout.json create mode 100644 internal/cronoclient/testdata/cronometer/CAPTURE.md create mode 100644 internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md create mode 100644 tools/wirecapture/README.md create mode 100644 tools/wirecapture/base64.go create mode 100644 tools/wirecapture/go.mod create mode 100644 tools/wirecapture/go.sum create mode 100644 tools/wirecapture/main.go create mode 100644 tools/wirecapture/recorder.go diff --git a/.gitignore b/.gitignore index 97b0008..0a1ae84 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,12 @@ crono-export crono-export-linux-amd64 *.zip *.tar.gz + +# Wire-capture dumps from tools/wirecapture/ — contain raw cookies, +# anti-CSRF tokens, password POST bodies, and real account data. Never +# commit. Redacted fixtures live in +# internal/cronoclient/testdata/cronometer/ and are committed. +cronometer-capture/ +tools/wirecapture/captures/ +tools/wirecapture/wirecapture +*.unredacted.json diff --git a/internal/cronoclient/testdata/cronometer/001_login_get.json b/internal/cronoclient/testdata/cronometer/001_login_get.json new file mode 100644 index 0000000..1f01edc --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/001_login_get.json @@ -0,0 +1,47 @@ +{ + "seq": 1, + "label": "login_get", + "reqMethod": "GET", + "reqUrl": "https://cronometer.com/login/", + "reqHeader": {}, + "reqBodySummary": null, + "respStatus": 200, + "respHeader": { + "Content-Type": [ + "text/html;charset=UTF-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:14 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure", + "=; Path=/; Secure; HttpOnly", + "=; Max-Age=172800; Expires=Thu, 14 May 2026 02:18:14 GMT; Path=/; Secure; HttpOnly" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "Vary": [ + "accept-encoding" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 683935, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 105 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/002_login_post.json b/internal/cronoclient/testdata/cronometer/002_login_post.json new file mode 100644 index 0000000..d40a5a7 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/002_login_post.json @@ -0,0 +1,58 @@ +{ + "seq": 2, + "label": "login_post", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/login", + "reqHeader": { + "Content-Type": [ + "application/x-www-form-urlencoded" + ], + "Cookie": [ + "" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 104, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Type": [ + "application/json;charset=UTF-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:14 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure", + "=; Max-Age=1209600; Expires=Tue, 26 May 2026 02:18:14 GMT; Path=/; Secure; HttpOnly; SameSite=Lax", + "=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/; Secure; HttpOnly" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "Vary": [ + "accept-encoding" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 39, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 138 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/003_gwt_authenticate.json b/internal/cronoclient/testdata/cronometer/003_gwt_authenticate.json new file mode 100644 index 0000000..b25ce6e --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/003_gwt_authenticate.json @@ -0,0 +1,63 @@ +{ + "seq": 3, + "label": "gwt_authenticate", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 179, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:14 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure", + "=; Max-Age=1209600; Expires=Tue, 26 May 2026 02:18:14 GMT; Path=/; Secure; HttpOnly; SameSite=Lax" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 8294, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 54 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/004_gwt_generate_auth_token_servings.json b/internal/cronoclient/testdata/cronometer/004_gwt_generate_auth_token_servings.json new file mode 100644 index 0000000..1262ffa --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/004_gwt_generate_auth_token_servings.json @@ -0,0 +1,65 @@ +{ + "seq": 4, + "label": "gwt_generate_auth_token_servings", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 294, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Length": [ + "48" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:14 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 48, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 25 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/005_export_servings.json b/internal/cronoclient/testdata/cronometer/005_export_servings.json new file mode 100644 index 0000000..9b09d8d --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/005_export_servings.json @@ -0,0 +1,61 @@ +{ + "seq": 5, + "label": "export_servings", + "reqMethod": "GET", + "reqUrl": "https://cronometer.com/export?end=2026-05-11&generate=servings&nonce=%3CREDACTED%3E&start=2026-05-04", + "reqHeader": { + "Cookie": [ + "" + ], + "Sec-Fetch-Dest": [ + "document" + ], + "Sec-Fetch-Mode": [ + "navigate" + ], + "Sec-Fetch-Site": [ + "same-origin" + ] + }, + "reqBodySummary": null, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment; filename=\"servings.csv\"" + ], + "Content-Length": [ + "6507" + ], + "Content-Type": [ + "text/csv" + ], + "Date": [ + "Tue, 12 May 2026 02:18:14 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 6500, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 59 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/006_gwt_generate_auth_token_exercises.json b/internal/cronoclient/testdata/cronometer/006_gwt_generate_auth_token_exercises.json new file mode 100644 index 0000000..14f3962 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/006_gwt_generate_auth_token_exercises.json @@ -0,0 +1,65 @@ +{ + "seq": 6, + "label": "gwt_generate_auth_token_exercises", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 294, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Length": [ + "48" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:14 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 48, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 26 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/007_export_exercises.json b/internal/cronoclient/testdata/cronometer/007_export_exercises.json new file mode 100644 index 0000000..03fd8b5 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/007_export_exercises.json @@ -0,0 +1,61 @@ +{ + "seq": 7, + "label": "export_exercises", + "reqMethod": "GET", + "reqUrl": "https://cronometer.com/export?end=2026-05-11&generate=exercises&nonce=%3CREDACTED%3E&start=2026-05-04", + "reqHeader": { + "Cookie": [ + "" + ], + "Sec-Fetch-Dest": [ + "document" + ], + "Sec-Fetch-Mode": [ + "navigate" + ], + "Sec-Fetch-Site": [ + "same-origin" + ] + }, + "reqBodySummary": null, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment; filename=\"exercises.csv\"" + ], + "Content-Length": [ + "43" + ], + "Content-Type": [ + "text/csv" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:14 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 43, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 47 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/008_gwt_generate_auth_token_biometrics.json b/internal/cronoclient/testdata/cronometer/008_gwt_generate_auth_token_biometrics.json new file mode 100644 index 0000000..a93f24a --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/008_gwt_generate_auth_token_biometrics.json @@ -0,0 +1,65 @@ +{ + "seq": 8, + "label": "gwt_generate_auth_token_biometrics", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 294, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Length": [ + "48" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 48, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 26 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/009_export_biometrics.json b/internal/cronoclient/testdata/cronometer/009_export_biometrics.json new file mode 100644 index 0000000..835ae12 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/009_export_biometrics.json @@ -0,0 +1,61 @@ +{ + "seq": 9, + "label": "export_biometrics", + "reqMethod": "GET", + "reqUrl": "https://cronometer.com/export?end=2026-05-11&generate=biometrics&nonce=%3CREDACTED%3E&start=2026-05-04", + "reqHeader": { + "Cookie": [ + "" + ], + "Sec-Fetch-Dest": [ + "document" + ], + "Sec-Fetch-Mode": [ + "navigate" + ], + "Sec-Fetch-Site": [ + "same-origin" + ] + }, + "reqBodySummary": null, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment; filename=\"biometrics.csv\"" + ], + "Content-Length": [ + "29" + ], + "Content-Type": [ + "text/csv" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 29, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 42 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/010_gwt_generate_auth_token_dailysummary.json b/internal/cronoclient/testdata/cronometer/010_gwt_generate_auth_token_dailysummary.json new file mode 100644 index 0000000..82fc683 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/010_gwt_generate_auth_token_dailysummary.json @@ -0,0 +1,65 @@ +{ + "seq": 10, + "label": "gwt_generate_auth_token_dailysummary", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 294, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Length": [ + "48" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 48, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 27 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/011_export_dailysummary.json b/internal/cronoclient/testdata/cronometer/011_export_dailysummary.json new file mode 100644 index 0000000..4ffa841 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/011_export_dailysummary.json @@ -0,0 +1,61 @@ +{ + "seq": 11, + "label": "export_dailysummary", + "reqMethod": "GET", + "reqUrl": "https://cronometer.com/export?end=2026-05-11&generate=dailySummary&nonce=%3CREDACTED%3E&start=2026-05-04", + "reqHeader": { + "Cookie": [ + "" + ], + "Sec-Fetch-Dest": [ + "document" + ], + "Sec-Fetch-Mode": [ + "navigate" + ], + "Sec-Fetch-Site": [ + "same-origin" + ] + }, + "reqBodySummary": null, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment; filename=\"dailysummary.csv\"" + ], + "Content-Length": [ + "2244" + ], + "Content-Type": [ + "text/csv" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 2239, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 59 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/012_gwt_generate_auth_token_notes.json b/internal/cronoclient/testdata/cronometer/012_gwt_generate_auth_token_notes.json new file mode 100644 index 0000000..3dfee92 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/012_gwt_generate_auth_token_notes.json @@ -0,0 +1,65 @@ +{ + "seq": 12, + "label": "gwt_generate_auth_token_notes", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 294, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Length": [ + "48" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 48, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 26 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/013_export_notes.json b/internal/cronoclient/testdata/cronometer/013_export_notes.json new file mode 100644 index 0000000..952b28d --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/013_export_notes.json @@ -0,0 +1,61 @@ +{ + "seq": 13, + "label": "export_notes", + "reqMethod": "GET", + "reqUrl": "https://cronometer.com/export?end=2026-05-11&generate=notes&nonce=%3CREDACTED%3E&start=2026-05-04", + "reqHeader": { + "Cookie": [ + "" + ], + "Sec-Fetch-Dest": [ + "document" + ], + "Sec-Fetch-Mode": [ + "navigate" + ], + "Sec-Fetch-Site": [ + "same-origin" + ] + }, + "reqBodySummary": null, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment; filename=\"notes.csv\"" + ], + "Content-Length": [ + "15" + ], + "Content-Type": [ + "text/csv" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 15, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 42 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/014_gwt_logout.json b/internal/cronoclient/testdata/cronometer/014_gwt_logout.json new file mode 100644 index 0000000..90b1b09 --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/014_gwt_logout.json @@ -0,0 +1,69 @@ +{ + "seq": 14, + "label": "gwt_logout", + "reqMethod": "POST", + "reqUrl": "https://cronometer.com/cronometer/app", + "reqHeader": { + "Content-Type": [ + "text/x-gwt-rpc; charset=UTF-8" + ], + "Cookie": [ + "" + ], + "X-Gwt-Module-Base": [ + "https://cronometer.com/cronometer/" + ], + "X-Gwt-Permutation": [ + "7B121DC5483BF272B1BC1916DA9FA963" + ] + }, + "reqBodySummary": { + "kind": "text", + "len": 200, + "note": "Body elided. See WIRE_SHAPES.md for the synthesized request shape for this endpoint." + }, + "respStatus": 200, + "respHeader": { + "Content-Disposition": [ + "attachment" + ], + "Content-Length": [ + "12" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Tue, 12 May 2026 02:18:15 GMT" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Set-Cookie": [ + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/", + "=; Expires=Tue, 19 May 2026 02:18:15 GMT; Path=/; SameSite=None; Secure", + "=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/; Secure; HttpOnly", + "=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/; Secure; HttpOnly", + "=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/; Secure; HttpOnly", + "=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/; Secure; HttpOnly" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "sameorigin" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "respBodySummary": { + "kind": "text", + "len": 12, + "note": "Body elided (contains real account data + tokens). See WIRE_SHAPES.md for the synthesized response shape for this endpoint." + }, + "durationMsApprox": 27 +} \ No newline at end of file diff --git a/internal/cronoclient/testdata/cronometer/CAPTURE.md b/internal/cronoclient/testdata/cronometer/CAPTURE.md new file mode 100644 index 0000000..c36d4af --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/CAPTURE.md @@ -0,0 +1,98 @@ +# Re-capturing Cronometer wire fixtures + +Phase 3 of the clean-room replacement uses captured HTTP behaviour as +its source of truth for the wire layer. Use this playbook when you need +to refresh the captures (e.g., Cronometer rotated the GWT permutation +hash, added an endpoint, or you need a previously-uncaptured failure +mode). + +The capture tool itself lives at `tools/wirecapture/`. + +## What you need + +- A real Cronometer account with a small amount of recent data — at + minimum, a few logged servings in the last 7 days, so the + `servings` CSV is non-trivial. 2FA off (the capture tool does not + prompt for a TOTP code). +- Local `go` toolchain (anything matching the version in `go.mod`). +- A working directory outside the repo for the unredacted dumps. They + contain raw cookies, session tokens, the password POST body, and your + Cronometer profile data. They must never be committed. + +## Steps + +1. Build the capture tool: + + ```bash + cd tools/wirecapture + go build -o /tmp/wirecapture . + ``` + +2. Pick a local-only output directory: + + ```bash + mkdir -p ~/cronometer-capture + chmod 700 ~/cronometer-capture + ``` + +3. Run the capture against a recent window. **Inline the credentials + into the subshell only**; do not `export` them into your interactive + shell history. + + ```bash + WIRE_CAPTURE_DIR=$HOME/cronometer-capture \ + CRONOMETER_USERNAME='you@example.com' \ + CRONOMETER_PASSWORD='your-password' \ + /tmp/wirecapture --since 2026-05-04 --until 2026-05-11 + ``` + + Expect 14 numbered JSON files in `$WIRE_CAPTURE_DIR`: one per HTTP + exchange. Each file holds the request method/URL/headers/body and + the response status/headers/body verbatim. + +4. Inspect the unredacted captures locally to understand any new + behaviour. Do not paste them into PRs, issues, comments, or commit + messages. + +5. Produce the redacted fixtures committed under + `internal/cronoclient/testdata/cronometer/`. Use the redaction script + approach in + [QUA-37 phase 3a PR](https://github.com/quantcli/crono-export-cli/pulls) + as a reference: strip cookie values, anti-CSRF token, all per-session + tokens (32-char hex), the password POST body, the username, and any + real account data from response bodies. Never commit response + bodies that contain real user data. + +## Sensitive content guide + +These appear in unredacted captures and **must be stripped before +commit**: + +| Where | What | How to redact | +| ----- | ---- | ------------- | +| `Cookie:` request headers | session cookies | replace value with `` | +| `Set-Cookie:` response headers | session cookies (4 in step 1, 2 in step 2, 1 elsewhere) | replace value but keep the cookie name shape | +| step (1) response body | full login HTML incl. `` | drop the body; document the input shape in `WIRE_SHAPES.md` | +| step (2) request body | `anticsrf=...&password=...&username=...` | drop the body; document key order in `WIRE_SHAPES.md` | +| step (3) response body | `authenticate` GWT response — full user payload incl. name, weight, height, DOB, timezone, custom-charts JSON, session auth token | drop the body | +| step (4/6/8/10/12) response body | `generateAuthorizationToken` response with the per-export nonce | drop the body | +| step (5/7/9/11/13) response body | CSV with real food/biometric/exercise/note data | drop the body; preserve only the column header row in a `headers/.csv` companion if needed | +| step (5/7/9/11/13) request URL `nonce=` query param | per-export auth token | replace with `` | +| step (14) request body | `logout` GWT call carrying the session auth token | drop the body | + +These are **not sensitive** and may be committed verbatim: + +- `X-Gwt-Permutation` header value — public deployment hash served to + every browser. +- `X-Gwt-Module-Base` header value — public URL. +- `Content-Type`, `Date`, `Strict-Transport-Security`, etc. — server + policy headers. +- Query string keys (`start`, `end`, `generate`) — public surface. + +## How to add a new endpoint + +If you need to capture a behaviour the existing capture binary does not +exercise (e.g., a fresh export type, a deliberate error path), edit +`tools/wirecapture/main.go` to add a new step. Each step is a closure +that calls a `gocronometer.Client` method; the recording transport +handles dump generation transparently. diff --git a/internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md b/internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md new file mode 100644 index 0000000..a645cda --- /dev/null +++ b/internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md @@ -0,0 +1,300 @@ +# Cronometer wire shapes — synthesized from a real capture session + +Status: **draft, Phase 3a of [QUA-12](https://github.com/quantcli/common) plan v4 / [QUA-37](https://github.com/quantcli/common)**. +Captured: 2026-05-12 against `cronometer.com` with a real test account. + +This document records the **observable HTTP behaviour** of `cronometer.com` +during a full crono-export-cli session, captured by routing the existing +(gocronometer-backed) CLI through the recording transport in +`tools/wirecapture/`. Per the [Phase 1 spec](../../../../docs/cronometer-protocol.md), +captured HTTP behaviour is an explicitly allowed input for the +clean-room replacement — `gocronometer`'s source remains out of scope. + +The companion `*.json` files in this directory are redacted captures of +each exchange: only metadata (URL, headers, status) survives, and all +cookie values, anti-CSRF tokens, per-session auth tokens, and real +account data are stripped. The synthesized shapes below stand in for +the bodies, with placeholder values clearly marked. + +## Session storyboard + +A complete export session is exactly 14 HTTP exchanges in this order: + +| # | Method | URL | Purpose | +| -- | ------ | ------------------------------------------- | ------------------------------------------------------ | +| 1 | GET | `https://cronometer.com/login/` | Anonymous page load. Sets initial cookies. Returns HTML containing the anti-CSRF token. | +| 2 | POST | `https://cronometer.com/login` | Submit credentials. Returns JSON; sets the authenticated session cookies. | +| 3 | POST | `https://cronometer.com/cronometer/app` | GWT-RPC `authenticate`. Returns the user payload (incl. session-scoped auth token). | +| 4 | POST | `https://cronometer.com/cronometer/app` | GWT-RPC `generateAuthorizationToken`. Mints the per-export `nonce` for `servings`. | +| 5 | GET | `https://cronometer.com/export?...&generate=servings&nonce=…` | Servings CSV download. | +| 6 | POST | `https://cronometer.com/cronometer/app` | `generateAuthorizationToken` for `exercises`. | +| 7 | GET | `https://cronometer.com/export?...&generate=exercises&nonce=…` | Exercises CSV download. | +| 8 | POST | `https://cronometer.com/cronometer/app` | `generateAuthorizationToken` for `biometrics`. | +| 9 | GET | `https://cronometer.com/export?...&generate=biometrics&nonce=…` | Biometrics CSV download. | +| 10 | POST | `https://cronometer.com/cronometer/app` | `generateAuthorizationToken` for `dailySummary`. | +| 11 | GET | `https://cronometer.com/export?...&generate=dailySummary&nonce=…` | Daily nutrition CSV download. | +| 12 | POST | `https://cronometer.com/cronometer/app` | `generateAuthorizationToken` for `notes`. | +| 13 | GET | `https://cronometer.com/export?...&generate=notes&nonce=…` | Notes CSV download. | +| 14 | POST | `https://cronometer.com/cronometer/app` | GWT-RPC `logout`. | + +A fresh per-export `nonce` is required for each `/export` GET; reusing +one is not exercised here and should be avoided in the clean-room +implementation. + +## (1) GET `/login/` + +Anonymous request, no required headers beyond a permissive `User-Agent`. + +The response is the login page HTML (~680KB). The clean-room client only +needs two things out of it: + +- **The anti-CSRF token**, embedded as a hidden form input. Shape (extracted + literally from the captured HTML, with the value redacted): + + ```html + + ``` + + Suggested extraction: regex `name="anticsrf"\s+value="([^"]+)"` against + the response body. Robust to surrounding markup churn. + +- **Session cookies** set via `Set-Cookie` (4 entries observed). Names and + values rotate per-session; the clean-room client just needs to use a + `*http.cookiejar.Jar` so they round-trip on subsequent requests. Do not + hardcode cookie names. + +## (2) POST `/login` + +Form-encoded credential submission. + +Request: + +``` +POST /login HTTP/1.1 +Content-Type: application/x-www-form-urlencoded +Cookie: + +anticsrf=&password=&username= +``` + +Body key order observed: `anticsrf`, `password`, `username`. Cronometer +likely accepts any order, but matching observed order is safer. + +Response on success: HTTP 200, `Content-Type: application/json;charset=UTF-8`, +body length ~39 chars. Sets two additional `Set-Cookie` entries (the +authenticated session). The exact JSON success body was not captured in +detail (small enough that future captures should preserve it verbatim +into a redacted fixture). Future capture TODO: capture the success body +shape and add it here. + +Response on bad CSRF: HTTP 200 with body `{"error":"AntiCSRF Token Invalid"}` +(literal, observed during a debugging run where cookies were not being +round-tripped). Other failure shapes are TBD — capture the +"bad-credentials" and "rate-limited" cases in a future run. + +## (3) POST `/cronometer/app` — GWT-RPC `authenticate` + +All `/cronometer/app` calls are GWT-RPC. The request and response wire +format is GWT's text-pipe-delimited stream, not JSON. Required headers: + +``` +Content-Type: text/x-gwt-rpc; charset=UTF-8 +X-Gwt-Module-Base: https://cronometer.com/cronometer/ +X-Gwt-Permutation: <32-char-uppercase-hex hash> +Cookie: +``` + +The `X-Gwt-Permutation` header value is the **deployed GWT permutation +hash** — the same value any anonymous browser visiting `cronometer.com` +would send for the same browser/locale. It is public, but it does change +when Cronometer redeploys their frontend. The clean-room client should +either (a) hardcode the current value as a default and surface a clear +error when Cronometer rotates it, or (b) discover it dynamically by +parsing the GWT module nocache JS served at `cronometer.com/cronometer/cronometer.nocache.js` +or similar. The captured value as of 2026-05-12 was +`7B121DC5483BF272B1BC1916DA9FA963` — record-only; assume it will rotate. + +### Request body shape (GWT-RPC encoding) + +GWT-RPC frames a method call as `|`-delimited fields: + +``` +7|0||||||... +``` + +For `authenticate`, the captured payload was 179 bytes shaped like: + +``` +7|0|5|https://cronometer.com/cronometer/|<32-char-hex permutation>|com.cronometer.shared.rpc.CronometerService|authenticate|java.lang.Integer/3438268394|1|2|3|4|1|5|5|| +``` + +Trailing `||` was `-300` in the capture — almost certainly the +caller's UTC offset in minutes (-300 ≈ America/New_York DST). The +clean-room client should send the local UTC offset of the host running +the CLI. + +The leading `7|0|N|...` framing is a GWT version + flags + arg-count +prefix. The trailing `1|2|3|4|1|5|5||` chunk is GWT's interned +string-table back-references; treat it as part of the literal call +template for `authenticate(int)` and re-emit it verbatim until we have a +reason to vary it. + +### Response body shape + +Response was 8294 bytes. Wire format: + +``` +//OK[] +``` + +The body is a flat array of integers, floats, quoted strings, and +back-references that decode into a `User` object plus its preferences, +custom-charts JSON, gold status, etc. Concrete fields the +clean-room client must extract from this response: + +- `userId` (integer at the start of the array; e.g., the first 7-9 digit + decimal value). +- `authToken` (32-char hex string near the end of the array, also passed + back as the first arg of `generateAuthorizationToken` in the + subsequent calls — see step 4). +- Everything else (custom charts, macro targets, body-fat history) can + be ignored; `crono-export-cli` does not consume it. + +**Robust decoder approach:** rather than hand-decoding the GWT string +table, the clean-room client can scrape the response with two regexes: + +```go +userIDRe = regexp.MustCompile(`^//OK\[(\d+),`) +authTokenRe = regexp.MustCompile(`"([0-9a-f]{32})"`) +``` + +`authTokenRe` will match multiple 32-hex strings; experimentally the +session auth token appears as the **last** such string in the +response that is followed immediately by other quoted entries (it is +the token that subsequent `generateAuthorizationToken` calls echo back +as their first argument). Future captures should pin this down by +diffing against a recapture; for Phase 3b, reflecting back the same +token that step 14 (`logout`) sends in its body is a sufficient +correctness check. + +## (4 / 6 / 8 / 10 / 12) POST `/cronometer/app` — GWT-RPC `generateAuthorizationToken` + +Same headers as (3). Five identical-shape calls, one per export type. + +### Request body shape + +Captured payload was 232 bytes; framing: + +``` +7|0|8|https://cronometer.com/cronometer/|<32-char-hex permutation>|com.cronometer.shared.rpc.CronometerService|generateAuthorizationToken|java.lang.String/2004016611|I|com.cronometer.shared.user.AuthScope/2065601159|<32-char session-auth-token>|1|2|3|4|4|5|6|6|7|8||3600|7|2| +``` + +Field semantics: + +- `<32-char session-auth-token>` — the auth token from the + `authenticate` response (step 3). +- `` — the user ID from the `authenticate` response. +- The literal `3600` is almost certainly a TTL in seconds (1 hour) — the + newly-minted nonce's lifetime. +- Trailing `7|2|` may correspond to an `AuthScope` enum value. The five + capture flows all sent the same `7|2|` suffix, so a single value + appears to cover all five export types. Vary only if a future capture + proves otherwise. + +### Response body shape + +Response was 48 bytes: + +``` +//OK[1,["<32-char-hex export-nonce>"],0,7] +``` + +Extract the 32-hex nonce; pass it as `nonce=` on the next +`/export` GET. + +## (5 / 7 / 9 / 11 / 13) GET `/export` + +CSV download. Required headers: just the session cookies. + +``` +GET /export?start=YYYY-MM-DD&end=YYYY-MM-DD&generate=&nonce=<32-char-hex> +``` + +Query-string keys observed (alphabetically here, but query-string order +is not significant on the wire): + +| Key | Value | +| -------- | -------------------------------------------------------- | +| start | `YYYY-MM-DD`, the local-calendar start of the inclusive window | +| end | `YYYY-MM-DD`, the local-calendar end of the inclusive window | +| generate | `servings` \| `exercises` \| `biometrics` \| `dailySummary` \| `notes` | +| nonce | the 32-char hex token from the immediately-preceding `generateAuthorizationToken` | + +Response: HTTP 200, `Content-Type: text/csv`. Body is RFC 4180-shaped +CSV with a header row. + +CSV body sizes captured for an 8-day window with low-traffic data: + +- `servings` → 6500 bytes (real food log present) +- `exercises` → 43 bytes (header only — no exercises in window) +- `biometrics` → 29 bytes (header only — no biometric entries in window) +- `dailySummary` → 2239 bytes (8 days of summary rows) +- `notes` → 15 bytes (header only — no notes in window) + +The exact CSV column headers were not preserved in the redacted +fixtures — future capture TODO: extract just the header row from each +CSV and commit it as `headers/.csv` (header-only CSVs contain no +account data and are safe to commit). The Phase 1 spec already +documents the typed-record field names; the Phase 3b implementer can +confirm the column-name mapping by capturing a fresh header row and +diffing against the spec's `` convention. + +## (14) POST `/cronometer/app` — GWT-RPC `logout` + +Same headers as (3). + +### Request body shape + +Captured payload was 162 bytes: + +``` +7|0|6|https://cronometer.com/cronometer/|<32-char-hex permutation>|com.cronometer.shared.rpc.CronometerService|logout|java.lang.String/2004016611|<32-char session-auth-token>|1|2|3|4|1|5|6| +``` + +Pass the same session auth token from step 3. + +### Response body shape + +Response was 12 bytes; success body shape: `//OK[1,0,7]`. + +Logout is best-effort; `crono-export-cli` already calls it via `defer` +and ignores errors, and the clean-room replacement should preserve +that. + +## Things explicitly NOT captured in this run + +These should be filled in by future captures, ideally before Phase 3b +locks the API: + +- **Long-window export pagination** — captured window was 8 days with + low-volume data. Cronometer's behaviour for very long windows + (multi-year, large data sets) is unknown. If `crono-export-cli` users + hit a row cap, we need to know what shape it takes (HTTP error vs + truncated CSV vs continuation cursor). +- **Auth failure response shapes** — bad password, expired session, + rate-limit. We saw `{"error":"AntiCSRF Token Invalid"}` only as a + side-effect of a recorder bug; the production failure modes are TBD. +- **Two-factor auth (2FA)** — the captured login HTML included a + `name="userCode"` 6-digit input, suggesting Cronometer supports TOTP. + Account used for capture had 2FA off; the 2FA-on flow is TBD. +- **Logout without a prior login** — defensive behaviour TBD. +- **Concurrent sessions** — does a fresh login invalidate existing + cookies? Out of scope for the export use-case. +- **Exact JSON success body shape for POST `/login`** — content was 39 + bytes but not preserved; recapture and document. +- **Exact CSV header row contents** — recapture header-only and commit + to `headers/.csv`. + +## Reproducing this capture + +See [`CAPTURE.md`](CAPTURE.md) in this directory. diff --git a/tools/wirecapture/README.md b/tools/wirecapture/README.md new file mode 100644 index 0000000..ce54ca4 --- /dev/null +++ b/tools/wirecapture/README.md @@ -0,0 +1,42 @@ +# wirecapture — developer-only Cronometer capture tool + +`wirecapture` drives the existing `gocronometer`-backed export flow +through a recording `http.RoundTripper` and writes one JSON file per +HTTP exchange to disk. It is the source of truth for the redacted wire +fixtures used by the clean-room client tests in Phase 3b of [QUA-12](https://github.com/quantcli/common). + +## Why a separate Go module + +This module imports `github.com/jrmycanady/gocronometer` (GPL-2.0). If +it lived in the main `crono-export-cli` module, the GPL transitive +dependency would re-contaminate the published binary's license claim. +Separate `go.mod` keeps `tools/wirecapture/` out of the production +module's `go.sum` and out of `go mod why`. + +After Phase 3b drops `gocronometer` from the main module, this tool +remains buildable independently and continues to work for re-capturing +fixtures. + +## Authorship + +`wirecapture` was authored without consulting `gocronometer`'s source. +Inputs to authorship were: + +- the public surface of `github.com/jrmycanady/gocronometer` as visible + via `go doc` (exported names, signatures, struct field names and + types), and +- `crono-export-cli`'s own MIT-licensed call sites + (`internal/cronoclient/client.go`, `cmd/auth.go`). + +The recording transport implementation in `recorder.go` is fresh-authored +under MIT and contains no fragments of `gocronometer` source. + +## Usage + +See the playbook in +[`internal/cronoclient/testdata/cronometer/CAPTURE.md`](../../internal/cronoclient/testdata/cronometer/CAPTURE.md). + +Output bytes contain raw session cookies, the anti-CSRF token, the +password POST body, all per-session auth tokens, and the captured user's +profile data. Never commit them. The `.gitignore` rules in this repo +already block `cronometer-capture/` and `tools/wirecapture/captures/`. diff --git a/tools/wirecapture/base64.go b/tools/wirecapture/base64.go new file mode 100644 index 0000000..0ed7c95 --- /dev/null +++ b/tools/wirecapture/base64.go @@ -0,0 +1,5 @@ +package main + +import "encoding/base64" + +func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) } diff --git a/tools/wirecapture/go.mod b/tools/wirecapture/go.mod new file mode 100644 index 0000000..9f7fe75 --- /dev/null +++ b/tools/wirecapture/go.mod @@ -0,0 +1,10 @@ +// Sub-module so the developer-only wirecapture tool can keep importing +// gocronometer (GPL-2.0) without contaminating crono-export-cli's main +// go.mod. The published binary never links this module. +module github.com/quantcli/crono-export-cli/tools/wirecapture + +go 1.25.10 + +require github.com/jrmycanady/gocronometer v1.5.1 + +require golang.org/x/net v0.46.0 // indirect diff --git a/tools/wirecapture/go.sum b/tools/wirecapture/go.sum new file mode 100644 index 0000000..ec7a086 --- /dev/null +++ b/tools/wirecapture/go.sum @@ -0,0 +1,4 @@ +github.com/jrmycanady/gocronometer v1.5.1 h1:m2J31jEuLlL4RRdQLY33IFs4TAwmfevvJYl2SZxBSQ0= +github.com/jrmycanady/gocronometer v1.5.1/go.mod h1:swnvYB6twU20LDzNpAz8JOX5mCHktTW06zlSXmmyZWc= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= diff --git a/tools/wirecapture/main.go b/tools/wirecapture/main.go new file mode 100644 index 0000000..3ee742b --- /dev/null +++ b/tools/wirecapture/main.go @@ -0,0 +1,143 @@ +// Command wirecapture drives the gocronometer client through the five +// export operations consumed by crono-export-cli, recording every HTTP +// exchange to disk for later use as Phase 3 fixtures. +// +// Authored without consulting gocronometer source. Inputs to authorship: +// +// - the public surface of github.com/jrmycanady/gocronometer (exported +// names, signatures, and field types as visible via go doc), and +// - crono-export-cli's own MIT-licensed call sites +// (internal/cronoclient/client.go). +// +// Use locally only: +// +// CRONOMETER_USERNAME=... CRONOMETER_PASSWORD=... \ +// WIRE_CAPTURE_DIR=$HOME/cronometer-capture \ +// go run ./tools/wirecapture +// +// Output files contain raw cookies, full responses, and may contain the +// password POST body. Do not commit them. Run the redaction step +// separately to produce sharable fixtures. +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "time" + + "github.com/jrmycanady/gocronometer" +) + +func main() { + var ( + dir = flag.String("dir", os.Getenv("WIRE_CAPTURE_DIR"), "directory to write captured exchanges to") + sinceS = flag.String("since", "", "start date YYYY-MM-DD (default: 7 days ago, local)") + untilS = flag.String("until", "", "end date YYYY-MM-DD (default: today, local)") + timeout = flag.Duration("timeout", 60*time.Second, "per-request timeout") + ) + flag.Parse() + + if *dir == "" { + fail("--dir or WIRE_CAPTURE_DIR is required") + } + user := os.Getenv("CRONOMETER_USERNAME") + pass := os.Getenv("CRONOMETER_PASSWORD") + if user == "" || pass == "" { + fail("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set") + } + if err := os.MkdirAll(*dir, 0o700); err != nil { + fail("mkdir %s: %v", *dir, err) + } + since, err := parseDate(*sinceS, time.Now().AddDate(0, 0, -7)) + if err != nil { + fail("--since: %v", err) + } + until, err := parseDate(*untilS, time.Now()) + if err != nil { + fail("--until: %v", err) + } + if since.After(until) { + fail("--since after --until") + } + + client := gocronometer.NewClient(nil) + if client.HTTPClient == nil { + client.HTTPClient = &http.Client{} + } + rec := newRecordingTransport(*dir, client.HTTPClient.Transport) + client.HTTPClient.Transport = rec + if *timeout > 0 { + client.HTTPClient.Timeout = *timeout + } + + ctx := context.Background() + logf("login as %s …", user) + if err := client.Login(ctx, user, pass); err != nil { + fail("login: %v", err) + } + + type step struct { + label string + run func(context.Context) error + } + steps := []step{ + {"servings", func(ctx context.Context) error { + _, err := client.ExportServingsParsedWithLocation(ctx, since, until, time.Local) + return err + }}, + {"exercises", func(ctx context.Context) error { + _, err := client.ExportExercisesParsedWithLocation(ctx, since, until, time.Local) + return err + }}, + {"biometrics", func(ctx context.Context) error { + _, err := client.ExportBiometricRecordsParsedWithLocation(ctx, since, until, time.Local) + return err + }}, + {"daily_nutrition", func(ctx context.Context) error { + _, err := client.ExportDailyNutrition(ctx, since, until) + return err + }}, + {"notes", func(ctx context.Context) error { + _, err := client.ExportNotes(ctx, since, until) + return err + }}, + } + + for _, s := range steps { + logf("%s [%s..%s] …", s.label, since.Format("2006-01-02"), until.Format("2006-01-02")) + if err := s.run(ctx); err != nil { + logf(" step %s failed: %v (continuing — partial capture preserved)", s.label, err) + } + } + + logf("logout …") + if err := client.Logout(ctx); err != nil { + logf(" logout failed: %v (continuing)", err) + } + + logf("done; %d exchanges written to %s", rec.seq.Load(), *dir) +} + +func parseDate(s string, fallback time.Time) (time.Time, error) { + if s == "" { + y, m, d := fallback.Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.Local), nil + } + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return time.Time{}, err + } + return t, nil +} + +func logf(format string, a ...any) { + fmt.Fprintf(os.Stderr, "[wirecapture] "+format+"\n", a...) +} + +func fail(format string, a ...any) { + fmt.Fprintf(os.Stderr, "wirecapture: "+format+"\n", a...) + os.Exit(1) +} diff --git a/tools/wirecapture/recorder.go b/tools/wirecapture/recorder.go new file mode 100644 index 0000000..1d89810 --- /dev/null +++ b/tools/wirecapture/recorder.go @@ -0,0 +1,172 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" +) + +type recordingTransport struct { + dir string + inner http.RoundTripper + seq atomic.Uint32 +} + +func newRecordingTransport(dir string, inner http.RoundTripper) *recordingTransport { + if inner == nil { + inner = http.DefaultTransport + } + return &recordingTransport{dir: dir, inner: inner} +} + +type exchange struct { + Seq uint32 `json:"seq"` + StartedAt time.Time `json:"startedAt"` + DurationMS int64 `json:"durationMs"` + ReqMethod string `json:"reqMethod"` + ReqURL string `json:"reqUrl"` + ReqHeader map[string][]string `json:"reqHeader"` + ReqBodyB64 string `json:"reqBodyB64,omitempty"` + ReqBodyText string `json:"reqBodyText,omitempty"` + RespStatus int `json:"respStatus,omitempty"` + RespHeader map[string][]string `json:"respHeader,omitempty"` + RespBodyB64 string `json:"respBodyB64,omitempty"` + RespBodyText string `json:"respBodyText,omitempty"` + Err string `json:"err,omitempty"` +} + +func (r *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + seq := r.seq.Add(1) + started := time.Now().UTC() + + reqBody, err := drainBody(req.Body) + if err != nil { + return nil, fmt.Errorf("wirecapture: read req body: %w", err) + } + if reqBody != nil { + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + } + + ex := exchange{ + Seq: seq, + StartedAt: started, + ReqMethod: req.Method, + ReqURL: req.URL.String(), + ReqHeader: cloneHeader(req.Header), + } + setBody(&ex.ReqBodyB64, &ex.ReqBodyText, reqBody, req.Header.Get("Content-Type")) + + resp, rtErr := r.inner.RoundTrip(req) + ex.DurationMS = time.Since(started).Milliseconds() + + if rtErr != nil { + ex.Err = rtErr.Error() + r.write(seq, req.Method, req.URL.Path, &ex) + return resp, rtErr + } + + respBody, readErr := drainBody(resp.Body) + if readErr != nil { + ex.Err = "wirecapture: read resp body: " + readErr.Error() + } + if respBody != nil { + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + } + ex.RespStatus = resp.StatusCode + ex.RespHeader = cloneHeader(resp.Header) + setBody(&ex.RespBodyB64, &ex.RespBodyText, respBody, resp.Header.Get("Content-Type")) + r.write(seq, req.Method, req.URL.Path, &ex) + return resp, nil +} + +func (r *recordingTransport) write(seq uint32, method, path string, ex *exchange) { + name := fmt.Sprintf("%03d-%s-%s.json", seq, strings.ToLower(method), sanitizePath(path)) + f, err := os.Create(filepath.Join(r.dir, name)) + if err != nil { + fmt.Fprintf(os.Stderr, "wirecapture: open %s: %v\n", name, err) + return + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(ex); err != nil { + fmt.Fprintf(os.Stderr, "wirecapture: encode %s: %v\n", name, err) + } +} + +func drainBody(body io.ReadCloser) ([]byte, error) { + if body == nil { + return nil, nil + } + defer body.Close() + return io.ReadAll(body) +} + +func cloneHeader(h http.Header) map[string][]string { + out := make(map[string][]string, len(h)) + for k, v := range h { + c := make([]string, len(v)) + copy(c, v) + out[k] = c + } + return out +} + +func setBody(b64Field, textField *string, body []byte, contentType string) { + if len(body) == 0 { + return + } + if isTextual(contentType, body) { + *textField = string(body) + return + } + *b64Field = base64Encode(body) +} + +func isTextual(contentType string, body []byte) bool { + ct := strings.ToLower(contentType) + switch { + case strings.HasPrefix(ct, "text/"), + strings.Contains(ct, "json"), + strings.Contains(ct, "xml"), + strings.Contains(ct, "x-www-form-urlencoded"), + strings.Contains(ct, "csv"): + return true + } + for _, b := range body { + if b == 0 { + return false + } + } + return true +} + +func sanitizePath(p string) string { + p = strings.TrimPrefix(p, "/") + if p == "" { + p = "root" + } + out := make([]rune, 0, len(p)) + for _, r := range p { + switch { + case r == '/' || r == '\\': + out = append(out, '_') + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.': + out = append(out, r) + default: + out = append(out, '_') + } + } + s := string(out) + if len(s) > 80 { + s = s[:80] + } + return s +}