diff --git a/CONTEXT.md b/CONTEXT.md index 12bd9572..211cdf55 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,15 @@ > and holds nothing of its own; this file does hold its own content, namely the standing rulings below. +> **2026-10-08 — Facebook Page: the decision home (spike `audits/SPIKE-facebook-page-api-2026-10-08.md`).** The +> Felhom.eu Page is run through a Meta **system user** (`felhom-cc`) owned by the Felhom business portfolio, via the +> Meta app `felhom.eu` (`2273465403490709`). Its token lives **only** in `~/.config/credentials` (`FACEBOOK_API`) on +> DooPlex; the Page token is derived at run time and never stored; no third-party SDK or MCP server touches it +> (operator ruling 2026-10-08). Posts go out **scheduled, for operator review, by default**. Ads are a separate app, +> not built. No architecture document covers this and none should: it is a business tool, not part of the product. +> Measured: token valid, `expires_at 0`, but `/me/accounts` empty — waits on the operator's asset click (R-915); +> the skill is R-914. + > **2026-10-08 (evening) — the decision sheet D1–D10 built on main, unreleased (`09` §3 185–194).** Controller (`3f84f82`): > operator actions (`internal/report/opactions.go`, closed list), dashboard sessions on disk as sha256 + password > fingerprint (`internal/web/session_store.go`), six health keys, the R-893 hold (`restore_mixed`) on every failure after diff --git a/REPORT-facebook-page-api.md b/REPORT-facebook-page-api.md new file mode 100644 index 00000000..a6607526 --- /dev/null +++ b/REPORT-facebook-page-api.md @@ -0,0 +1,50 @@ +# REPORT — spike: can Claude Code run the Felhom.eu Facebook Page? (2026-10-08) + +Own file, not `REPORT.md`: parallel sessions share this clone (`CLAUDE.md` workflow rule). + +## For the operator + +- The robot key works. It never runs out. +- The robot sees **no Page**. So the run stopped before any post. Nothing was posted. Nothing was deleted. +- **You do one click:** Meta Business Suite → Settings → Users → System users → `felhom-cc` → Assign assets → + Pages → Felhom.eu → Full control. If Felhom.eu is not in the list: Settings → Accounts → Pages → add it first. +- After that, tell Claude to re-run the probe. If you do nothing: Claude cannot post. Nothing else breaks. +- Later, before real public posts: switch the Meta app to „Live". It needs a Terms of Service web address. + +## Results + +| Scenario | Result | Evidence | +|---|---|---| +| A — key valid, long-lived | PASS: `SYSTEM_USER`, app 2273465403490709, `is_valid true`, `expires_at 0`, `data_access_expires_at 0` | `A1-debug-token.json` | +| B — reaches the Page | **FAIL: `/me/accounts` = `{"data": []}`**; robot = `felhom-cc` (122094150717513084) | `B1`, `B2` | +| C — read calls | not run (no Page token) | — | +| D — scheduled text post | not run (§8: no Page → stop) | — | +| E — scheduled photo | not run | — | +| F — headers | `facebook-api-version: v26.0`; `x-business-use-case-usage` recorded | `F1-headers.json` | +| F — docs (read) | dev-mode posts seen only by role users; own-Page use needs no App Review (Standard Access); Live needs ToS URL, icon, category, contact e-mail, app purpose | spike doc, with URLs | + +Read run twice; same result. Exit code 4 both times (B failed) — the brief's green gate „exit 0 on read" is not +met, because of the missing Page, not the script. + +- **Secret scan:** with the planted `EAAfakeprobe` control: 1 hit. After removing it: 0. Token tail search over + evidence, spike doc and script: 0. +- **Accent round-trip:** not measured (D/E not run). +- **Teardown:** Facebook — no post created, none to delete. Host — nothing provisioned. Hub — nothing created. +- **Register:** 136 before → 138 after; opened R-914 (skill, CC, BLOCKED on R-915), R-915 (operator, the asset + click + Live). Closed 0. + +## Files + +`scripts/facebook/fb_probe.py`, `scripts/facebook/test_fb_probe.py`, `scripts/CHANGELOG.md`, +`documentation/audits/SPIKE-facebook-page-api-2026-10-08.md`, `documentation/audits/facebook-page-api-2026-10-08/`, +`documentation/backlog/OPEN-ITEMS.md`, `CONTEXT.md`, `STATUS.md`, this report. + +## Observations + +- The granular scopes carry no `target_ids`, and `read_insights` is in `scopes` but not in `granular_scopes` — + NOT-A-FINDING: recorded in the spike table; re-check after R-915. +- Which of the two clicks is missing (asset assignment vs Page not in the portfolio) was not measured: + `/{business}/owned_pages` would answer, but it is outside the brief's call list (§9.7) — not run. Folded into R-915. +- The spec named `website/` for the logo; the probe uses `website/assets/logo.png` (PNG, 645×408 — Facebook photos + do not take SVG). +- No `Co-Authored-By` line on the commit: the brief forbids it (§12). diff --git a/STATUS.md b/STATUS.md index c16243e1..4fda85e9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -3,9 +3,18 @@ **Ready for the first real tester (Tester-2): yes. Tester 2 (a laptop) is off; nothing was sent to it.** **Updated 2026-10-08 (evening): hub 0.143.1; demo-hp, demo-felhom and Tester 1 run agent 0.153.0 and controller -0.303.0 (nothing delivered today — tonight is the second kernel night). The open-items list is at 136. Reports: +0.303.0 (nothing delivered today — tonight is the second kernel night). The open-items list is at 138. Reports: `REPORT-day-2026-10-08.md` (morning), `REPORT-day2-2026-10-08.md` (afternoon), `REPORT-day3-2026-10-08.md` (evening).** +## Facebook Page (2026-10-08): the key works, but sees no Page — needs you + +- The robot key works. It never runs out. +- The robot has no Page. So nothing was posted, and nothing was tested on the Page. +- **Needs you (one click):** Meta Business Suite → Settings → Users → System users → felhom-cc → Assign assets → + Pages → Felhom.eu → Full control. If you do nothing: Claude cannot post. Nothing else breaks. +- **Later, before real posts:** switch the Meta app to „Live". It needs a Terms of Service web address (the + legal pages item). Until then, posts are seen only by people with a role on the app. + ## Evening (2026-10-08): your ten answers (D1–D10) built — they ship tomorrow - **Buttons in the hub** (D1): run an off-site backup now, run one of four checks now, stop or extend a household's diff --git a/documentation/audits/SPIKE-facebook-page-api-2026-10-08.md b/documentation/audits/SPIKE-facebook-page-api-2026-10-08.md new file mode 100644 index 00000000..58cd50c6 --- /dev/null +++ b/documentation/audits/SPIKE-facebook-page-api-2026-10-08.md @@ -0,0 +1,71 @@ +# SPIKE — can Claude Code run the Felhom.eu Facebook Page? (2026-10-08) + +**Verdict: the key works and never expires, but it reaches NO Page.** `/me/accounts` is empty, so the write +phases (D, E) did not run (brief §8: no Page → stop) and nothing was posted. One operator click unblocks it. + +Baseline `felhom.eu` @ `fe0dc0d03f`. Probe: `scripts/facebook/fb_probe.py` (stdlib; tests `test_fb_probe.py`). +Evidence: `facebook-page-api-2026-10-08/` (one redacted JSON per call). Graph API **v26.0** accepted (no fallback). +Run twice (`read`), same result both times. Grades: **measured** = this run; **read** = Meta's documentation, not run. + +**Architecture: no document in `documentation/architecture/` covers marketing or social media** (checked: `00`–`12`). +That is correct — the Page is a business tool, not part of the product. The decision home is `CONTEXT.md` +(entry 2026-10-08, „Facebook Page") and the rows R-914 / R-915. + +## Findings + +| Question | Answer | Grade | Evidence | +|---|---|---|---| +| Is the key valid? | `is_valid: true` | measured | `A1-debug-token.json` | +| Token type / app | `SYSTEM_USER`, app `2273465403490709` (`felhom.eu`) | measured | `A1` | +| Does it expire? | `expires_at: 0`, `data_access_expires_at: 0` — never | measured | `A1` | +| Scopes | `read_insights, pages_show_list, business_management, pages_read_engagement, pages_read_user_content, pages_manage_posts, pages_manage_engagement, public_profile` | measured | `A1` | +| Granular scopes | the six `pages_*` + `business_management`, **none with `target_ids`**; `read_insights` and `public_profile` absent from the granular list | measured | `A1` | +| Who is the robot? | id `122094150717513084`, name `felhom-cc` | measured | `B1-me.json` | +| Which Page does it reach? | **none** — `/me/accounts` → `{"data": []}`, HTTP 200 | measured | `B2-me-accounts.json` | +| Page Graph ID vs browser ID `61595336666018` | not known — no Page reached | — | — | +| Page tasks / Page token | not reached | — | — | +| Read calls (Page, feed, insights) — C | not run (needs a Page token) | — | — | +| Text post + accents — D | not run (gate) | — | — | +| Photo post — E | not run (gate) | — | — | +| API version header | `facebook-api-version: v26.0` | measured | `F1-headers.json` | +| Rate header | `x-business-use-case-usage` keyed by business `4713349378884589`, type `business_integration_system_user_platform_endpoints`, all counts ≤ 1 | measured | `F1` | +| Dev-mode posts visible to the public? | **No.** „Any data generated while an app is in Development mode, such as test posts, can only be seen by role users" — and it becomes visible to everyone once the app goes Live | read | [app modes](https://developers.facebook.com/docs/development/build-and-test/app-modes) | +| Is App Review needed for our own Page? | **No** for Standard Access: it is automatic and covers users/assets with a role on the app; Advanced Access (review + business verification) is for other people's assets | read | [access levels](https://developers.facebook.com/docs/graph-api/overview/access-levels) | +| What does Live need? | display name, contact e-mail, **Terms of Service URL**, app icon, category, app purpose (each „required to switch your app to Live mode"); the page also lists a Privacy Policy URL and a data-deletion URL. The app-modes page says switch only after App Review | read | [basic settings](https://developers.facebook.com/docs/development/create-an-app/app-dashboard/basic-settings) | + +## The five operator questions + +1. **Does the key work, does it expire?** Yes, it works. It never expires. +2. **Which Page does it reach?** None yet. The robot has no Page assigned to it. +3. **Hungarian accents and a photo?** Not tested. The test needs a Page first. +4. **Does Meta block posting until App Review or Live?** Not measured. Meta's docs say our own Page needs no App + Review. Live is a separate switch (point 5). +5. **Are development-mode posts public?** No, says Meta's documentation. Only people with a role on the app see them. + So real public posts need the app switched to Live, and Live needs a Terms of Service web address (R-813 holds + the legal pages). + +## The click that unblocks it (R-915) + +Meta Business Suite → **Settings → Users → System users → `felhom-cc` → Assign assets → Pages → Felhom.eu → +Full control** (at least „Content", „Community activity", „Insights"). If the Page is not in the list, first: +**Settings → Accounts → Pages → Add → add the Felhom.eu Page** to the business portfolio `4713349378884589`. +Then re-run `python3 scripts/facebook/fb_probe.py -v read` (the same key should then see the Page — granular +scopes carry no `target_ids`, so the token is not pinned to a Page list; *inferred*, re-measure). +Which of the two clicks is missing was not measured: `/{business}/owned_pages` would say, but it is outside the +brief's call list (§9.7). + +## Open questions (for the re-run) + +- Page Graph ID beside `61595336666018`; its `tasks`; the Page token's `type` and `expires_at`. +- Are dev-mode scheduled posts accepted at all, or refused with (#200)/(#10)? +- Is the system user a „role user" on the app for the dev-mode visibility rule? Not stated in the docs read. +- Which insights metrics are alive in v26.0 (probe asks `page_post_engagements`, `page_follows`, `page_media_view`). +- Photo endpoint: does it return `id`, `post_id` or both, and which does DELETE need. + +## What the skill needs (so far) + +- Version `v26.0`; base `https://graph.facebook.com/v26.0/`. +- Token: `FACEBOOK_API` via `load_key()` (R-453 strip + asserts); sent as `Authorization: Bearer`, accepted on + `debug_token`, `/me`, `/me/accounts` (measured). +- Page token: derived at run time from `/me/accounts?fields=…,access_token`, memory only (not yet measured). +- Every response through `redact()`; HTTP 200 with an `error` body counts as failure. diff --git a/documentation/audits/facebook-page-api-2026-10-08/A1-debug-token.json b/documentation/audits/facebook-page-api-2026-10-08/A1-debug-token.json new file mode 100644 index 00000000..2afa6b62 --- /dev/null +++ b/documentation/audits/facebook-page-api-2026-10-08/A1-debug-token.json @@ -0,0 +1,63 @@ +{ + "api_version": "v26.0", + "at_utc": "2026-10-08T15:59:55Z", + "error": null, + "files": {}, + "form": {}, + "headers": { + "facebook-api-version": "v26.0", + "x-business-use-case-usage": "{\"4713349378884589\":[{\"type\":\"business_integration_system_user_platform_endpoints\",\"call_count\":1,\"total_cputime\":1,\"total_time\":1,\"estimated_time_to_regain_access\":0}]}", + "x-fb-rev": "1049703040", + "x-fb-trace-id": "FqLPoPms2rq" + }, + "http_status": 200, + "method": "GET", + "ok": true, + "path": "debug_token", + "query_keys": [ + "input_token" + ], + "response": { + "data": { + "app_id": "2273465403490709", + "application": "felhom.eu", + "data_access_expires_at": 0, + "expires_at": 0, + "granular_scopes": [ + { + "scope": "pages_show_list" + }, + { + "scope": "business_management" + }, + { + "scope": "pages_read_engagement" + }, + { + "scope": "pages_read_user_content" + }, + { + "scope": "pages_manage_posts" + }, + { + "scope": "pages_manage_engagement" + } + ], + "is_valid": true, + "issued_at": 1791474515, + "scopes": [ + "read_insights", + "pages_show_list", + "business_management", + "pages_read_engagement", + "pages_read_user_content", + "pages_manage_posts", + "pages_manage_engagement", + "public_profile" + ], + "type": "SYSTEM_USER", + "user_id": "122094150717513084" + } + }, + "step": "A1-debug-token" +} diff --git a/documentation/audits/facebook-page-api-2026-10-08/B1-me.json b/documentation/audits/facebook-page-api-2026-10-08/B1-me.json new file mode 100644 index 00000000..aaa04b39 --- /dev/null +++ b/documentation/audits/facebook-page-api-2026-10-08/B1-me.json @@ -0,0 +1,25 @@ +{ + "api_version": "v26.0", + "at_utc": "2026-10-08T15:59:55Z", + "error": null, + "files": {}, + "form": {}, + "headers": { + "facebook-api-version": "v26.0", + "x-app-usage": "{\"call_count\":0,\"total_cputime\":0,\"total_time\":0}", + "x-fb-rev": "1049703040", + "x-fb-trace-id": "FsRb63t9072" + }, + "http_status": 200, + "method": "GET", + "ok": true, + "path": "me", + "query_keys": [ + "fields" + ], + "response": { + "id": "122094150717513084", + "name": "felhom-cc" + }, + "step": "B1-me" +} diff --git a/documentation/audits/facebook-page-api-2026-10-08/B2-me-accounts.json b/documentation/audits/facebook-page-api-2026-10-08/B2-me-accounts.json new file mode 100644 index 00000000..9c5a17ef --- /dev/null +++ b/documentation/audits/facebook-page-api-2026-10-08/B2-me-accounts.json @@ -0,0 +1,24 @@ +{ + "api_version": "v26.0", + "at_utc": "2026-10-08T15:59:55Z", + "error": null, + "files": {}, + "form": {}, + "headers": { + "facebook-api-version": "v26.0", + "x-app-usage": "{\"call_count\":0,\"total_cputime\":0,\"total_time\":0}", + "x-fb-rev": "1049703040", + "x-fb-trace-id": "Boa1at49i//" + }, + "http_status": 200, + "method": "GET", + "ok": true, + "path": "me/accounts", + "query_keys": [ + "fields" + ], + "response": { + "data": [] + }, + "step": "B2-me-accounts" +} diff --git a/documentation/audits/facebook-page-api-2026-10-08/F1-headers.json b/documentation/audits/facebook-page-api-2026-10-08/F1-headers.json new file mode 100644 index 00000000..125ede69 --- /dev/null +++ b/documentation/audits/facebook-page-api-2026-10-08/F1-headers.json @@ -0,0 +1,7 @@ +{ + "facebook-api-version": "v26.0", + "x-app-usage": "{\"call_count\":0,\"total_cputime\":0,\"total_time\":0}", + "x-business-use-case-usage": "{\"4713349378884589\":[{\"type\":\"business_integration_system_user_platform_endpoints\",\"call_count\":1,\"total_cputime\":1,\"total_time\":1,\"estimated_time_to_regain_access\":0}]}", + "x-fb-rev": "1049703040", + "x-fb-trace-id": "FqLPoPms2rq" +} diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index e7e0978b..d3f730d7 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -263,7 +263,7 @@ stopping line that lies. | **R-814** | Hub & operator | P4 | `PBS-storage-1` (u629193, box 611421) still `status=active`, 19.9 MB | **VERIFY** (2026-10-03 triage: a July watch row with no id; given R-814. WAITING-ON-OPERATOR — no record found that the box was deleted.) — WAITING-ON-OPERATOR | operator console | Delete the box | operator | | **R-844** | Hub & operator | P4 | **The household's OS-update line exists only on the hub's customer timeline.** 2026-10-04: the box itself has no event surface for agent results (the controller UI shows no timeline), so `os_update_applied` is a hub customer event (info: recorded, never mailed). Its stored text is the hub's English sentence; the hu/en bundle text (`mail.event.os_update_applied`) is used only if it is ever mailed. Fix direction: a controller-side line (the controller already polls the agent's local API) when the box gets a household timeline. `audits/os-guest-lane-2026-10-04/partG/hub-customer-timeline-demo-hp.txt` | **READY — owner: CC** **2026-10-05 (burn-down night): NEEDS A DESIGN** — a household timeline on the box does not exist yet. | — | — | CC | -## Business & legal — 6 rows (P2 4, P4 2) +## Business & legal — 8 rows (P2 4, P4 4) | ID | Category | Sev | What | State | Blocked on | Next action | Owner | |---|---|---|---|---|---|---|---| @@ -274,6 +274,8 @@ stopping line that lies. | **R-901** | Business & legal | P2 | **Two kinds of a household's data outlive the deletion of the customer, and no document says when they go.** FOUND 2026-10-08 (R-813 drafting), read in source: the hub keeps `events` and `notification_log` on purpose after a customer is deleted (`hub/internal/store/customer_delete.go:24-26`, „the audit trail outlives every lifecycle tier") and nothing prunes `notification_log`; DooPlex's copy of ep0 (`ep0-copy`) pulls with `remove-vanished false` and prunes only to keep-weekly 8 (`runbooks/ep0-datastore-copy.md`), so a deleted customer's last 8 weekly encrypted whole-guest copies stay on DooPlex with no end date. A privacy notice cannot promise deletion until this is decided. **-- 2026-10-08 14:16 operator ruling D9 (`09` §3 decision 193):** yes — CC installs the DooPlex job, dry run first, then daily, after the 2026-10-09 releases are read back. | **READY — ruled 2026-10-08 09:04 (`09` §3 decision 181): audit rows (`events`, `notification_log`) of a deleted customer kept 1 year, then deleted; the customer's `ep0-copy` namespace removed within 30 days; both go into the privacy-notice draft.** Close only when both are live. | R-813 | Build the hub's daily deletion (ships with the next hub release) and the DooPlex `ep0-copy` removal job (written; run needs the operator's word); write both times into the privacy-notice draft | CC | | **R-89** | Business & legal | P4 | Retention as a per-customer **commercial** policy on the hub | READY (increment 2) | — | Policy object + reconciler → ep0 prune job; keep box tokens write-only | CC | | **R-794** | Business & legal | P4 | **[P3-LOW] redis 7.4 (RSALv2 / SSPL, not OSI) runs as a private cache in seven apps: dawarich, docmost, immich, nextcloud, outline, paperless-ngx, romm.** READ 2026-10-02 (`audits/licences-2026-10-02/TABLE.md`). Read as permitted (a private cache only its app uses is not Redis offered as a service — inferred). Valkey (BSD-3) or redis 8 (AGPL option) removes the question. **Needs:** a ladder step per app to valkey or redis 8, through the harness — no hurry. | **READY — rank P3-LOW; owner: CC** **Re-ranked 2026-10-03: P3→P4: the row itself says no hurry; usage read as permitted.** | — | — | CC | +| **R-914** | Business & legal | P4 | **Write the Felhom Facebook Page skill from the spike's findings.** Spike 2026-10-08 (`audits/SPIKE-facebook-page-api-2026-10-08.md`): the system-user key is valid and never expires, but reaches no Page, so the post/photo/read paths are unmeasured. Probe `scripts/facebook/fb_probe.py`. | **BLOCKED** — on R-915 (the Page is not assigned to the robot) | R-915 | After R-915: re-run `fb_probe.py -v read`, then `write-test` (scheduled post + photo, read back, deleted); finish the spike table; then write the skill (drafts scheduled for operator review by default) | CC | +| **R-915** | Business & legal | P4 | **The Facebook robot (`felhom-cc`) has no Page, and the Meta app is in development mode.** MEASURED 2026-10-08: `/me/accounts` returns `{"data": []}` (`audits/facebook-page-api-2026-10-08/B2-me-accounts.json`). READ (Meta docs, cited in the spike): posts made in development mode are seen only by people with a role on the app; Live needs display name, contact e-mail, a Terms of Service URL, an app icon, a category and the app purpose (privacy-policy and data-deletion URLs listed beside them). | **WAITING-ON-OPERATOR** | — | (1) Meta Business Suite → Settings → Users → System users → felhom-cc → Assign assets → Pages → Felhom.eu → Full control (if the Page is not listed: Settings → Accounts → Pages → Add it first). (2) Before real posts: switch the app to Live — needs the Terms of Service URL (R-813). If nothing is done: CC cannot post; nothing breaks | operator | ## Process & tooling — 23 rows (P3 3, P4 20) diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 66ce22fd..14d07fdb 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,11 @@ +## facebook — the Page access probe `scripts/facebook/fb_probe.py` (2026-10-08, spike) + +- Stdlib probe for the Meta Graph API (v26.0): `read` (debug_token, /me, /me/accounts, Page fields, feed, insights) and + `write-test` (one scheduled text post + one scheduled photo, read back as UTF-8 hex, deleted, removal proven by a + failed GET). No „post for real" command. Token only from `~/.config/credentials` via R-453's `unwrap`, sent as a + Bearer header; every saved response passes `redact()`. `test_fb_probe.py` (10 tests; red-proven against a copy with + the `access_token` drop and the `EAA` assert removed — 2 failures). Findings: `documentation/audits/SPIKE-facebook-page-api-2026-10-08.md`. + ## gates — the decoy suite runs in a linked git worktree (2026-10-08, fixed without a row) - `test_gate_decoys.py` took its lock at `ROOT/.git/decoy-suite.lock`; in a `git worktree add` copy `.git` is a FILE, so diff --git a/scripts/facebook/fb_probe.py b/scripts/facebook/fb_probe.py new file mode 100644 index 00000000..4c49f44e --- /dev/null +++ b/scripts/facebook/fb_probe.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""fb_probe.py — the Facebook Page access probe (spike 2026-10-08). Stdlib only. + +Usage: python3 scripts/facebook/fb_probe.py [-v] [--version v26.0] [--evidence DIR] read + python3 scripts/facebook/fb_probe.py [-v] [--version v26.0] [--evidence DIR] write-test + +`read` Scenarios A-C: the system-user token debugs itself, reaches the Felhom.eu Page, derives + the Page token (memory only), reads the Page, its feed and a few insights metrics. +`write-test` Scenarios D-E: ONE scheduled text post and ONE scheduled photo, a week ahead, each read + back (UTF-8 hex compare) and DELETED; removal is proven by a failed GET, never by + DELETE's own success. Refuses unless A and B pass and the Page grants CREATE_CONTENT. + +There is deliberately NO "post for real" sub-command: real posting belongs to the skill written +from this spike's findings (audits/SPIKE-facebook-page-api-2026-10-08.md). + +SECRETS. The token comes ONLY from ~/.config/credentials (key FACEBOOK_API); no flag takes one. +It travels as `Authorization: Bearer`, never in a logged URL (the one query-string carrier, +debug_token's input_token, is never printed: -v prints the path without its query). Every saved +response passes redact(): every `access_token` key is dropped and any string carrying a held token +or an `EAA…` token shape is replaced. The Page token never touches disk. + +Exit codes: 0 ok · 2 key refused · 3 Scenario A failed · 4 Scenario B failed · 5 write gate refused + · 6 a write was refused (stop, finding) · 7 a test object could not be proven removed + · 8 a test object read back PUBLISHED (deleted at once — read the report first) +""" +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, os.path.dirname(HERE)) +from read_credential import CredentialError, unwrap # noqa: E402 (R-453: the one quote-stripper) + +GRAPH = "https://graph.facebook.com" +APP_ID = "2273465403490709" +PAGE_NAME = "Felhom.eu" +KEY = "FACEBOOK_API" +DEFAULT_EVIDENCE = os.path.join(ROOT, "documentation", "audits", "facebook-page-api-2026-10-08") +LOGO = os.path.join(ROOT, "website", "assets", "logo.png") +WEEK_S = 7 * 24 * 3600 +# "Felhom teszt – árvíztűrő tükörfúrógép. Ez a bejegyzés törlődik." — built from escapes, never typed +# through a shell (brief §9.8). +TEST_TEXT = ("Felhom teszt – árvíztűrő tükörfúrógép. " + "Ez a bejegyzés törlődik.") +HEADERS_KEPT = ("facebook-api-version", "x-business-use-case-usage", "x-app-usage", "x-page-usage", + "x-fb-trace-id", "x-fb-rev") +TOKEN_SHAPE = re.compile(r"EAA[A-Za-z0-9]{10,}") + +VERBOSE = False +SECRETS = [] # every token value held this run; redact() scrubs each + + +def log(msg): + print(msg, flush=True) + + +def vlog(msg): + if VERBOSE: + print(" " + msg, flush=True) + + +# ---------------------------------------------------------------- the key (R-453) + +def load_key(path, key=KEY): + """Return the value of `key`. Accepts an optional `export ` prefix; ONE matching quote pair is + stripped by read_credential.unwrap. Then asserts: no quote, no whitespace, starts with EAA.""" + with open(path, encoding="utf-8") as fh: + for line in fh: + s = line.strip() + if s.startswith("export "): + s = s[len("export "):].lstrip() + if s.startswith(key + "="): + value = unwrap(s[len(key) + 1:].strip()) + if any(q in value for q in ("'", '"')): + raise CredentialError("value still contains a quote character") + if any(c.isspace() for c in value): + raise CredentialError("value contains whitespace") + if not value.startswith("EAA"): + raise CredentialError("value does not start with EAA (starts %r)" % value[:3]) + return value + raise CredentialError("key %r not present in %s" % (key, path)) + + +# ---------------------------------------------------------------- redaction + +def redact(obj, secrets=None): + """Deep copy of `obj` with every access_token key removed and every token-bearing string replaced.""" + secrets = SECRETS if secrets is None else secrets + if isinstance(obj, dict): + return {k: redact(v, secrets) for k, v in obj.items() if k != "access_token"} + if isinstance(obj, list): + return [redact(v, secrets) for v in obj] + if isinstance(obj, str): + if any(s and s in obj for s in secrets) or TOKEN_SHAPE.search(obj): + return "[REDACTED]" + return obj + + +# ---------------------------------------------------------------- HTTP + +class Call: + def __init__(self, step, method, path, status, body, headers, err): + self.step, self.method, self.path = step, method, path + self.status, self.body, self.headers, self.err = status, body, headers, err + + @property + def ok(self): + return self.err is None + + +class Graph: + def __init__(self, version, evidence): + self.version, self.evidence = version, evidence + self.headers_seen = {} + os.makedirs(evidence, exist_ok=True) + + def call(self, step, method, path, token, query=None, form=None, files=None): + """One Graph call. `path` is printed and saved; `query` is sent but NEVER printed or saved + when it carries a secret (it is saved only as the list of its keys).""" + url = "%s/%s/%s" % (GRAPH, self.version, path.lstrip("/")) + if query: + url += "?" + urllib.parse.urlencode(query) + data, ctype = None, None + if files: + data, ctype = multipart(form or {}, files) + elif form is not None: + data, ctype = urllib.parse.urlencode(form).encode("utf-8"), "application/x-www-form-urlencoded" + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Bearer " + token) + if ctype: + req.add_header("Content-Type", ctype) + status, raw, hdrs = None, b"", {} + try: + with urllib.request.urlopen(req, timeout=60) as r: + status, raw, hdrs = r.status, r.read(), r.headers + except urllib.error.HTTPError as e: + status, raw, hdrs = e.code, e.read(), e.headers + except urllib.error.URLError as e: + status, raw = None, json.dumps({"transport_error": str(e.reason)}).encode() + try: + body = json.loads(raw.decode("utf-8")) if raw else {} + except ValueError: + body = {"non_json_body": raw[:500].decode("utf-8", "replace")} + kept = {h: hdrs.get(h) for h in HEADERS_KEPT if hdrs and hdrs.get(h) is not None} + for h, v in kept.items(): + self.headers_seen.setdefault(h, v) + err = None + if status is None or not (200 <= status < 300): + err = body.get("error", body) if isinstance(body, dict) else body + elif isinstance(body, dict) and "error" in body: # Meta can say 200 and mean no (§9.3) + err = body["error"] + c = Call(step, method, path, status, body, kept, err) + vlog("%s %s -> HTTP %s%s" % (method, path, status, + "" if err is None else " ERROR " + json.dumps(redact(err), ensure_ascii=False))) + self.save(c, query, form, files) + return c + + def save(self, c, query, form, files): + rec = { + "step": c.step, "method": c.method, "path": c.path, "api_version": self.version, + "query_keys": sorted(query) if query else [], + "form": redact({k: v for k, v in (form or {}).items()}), + "files": {k: os.path.relpath(v, ROOT) for k, v in (files or {}).items()}, + "http_status": c.status, "headers": c.headers, "ok": c.ok, + "error": c.err, "response": c.body, + "at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + path = os.path.join(self.evidence, c.step + ".json") + with open(path, "w", encoding="utf-8") as fh: + json.dump(redact(rec), fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") + + +def multipart(fields, files): + boundary = "----felhomprobe" + uuid.uuid4().hex + out = [] + for k, v in fields.items(): + out += [b"--" + boundary.encode(), ('Content-Disposition: form-data; name="%s"' % k).encode(), b"", + str(v).encode("utf-8")] + for k, p in files.items(): + with open(p, "rb") as fh: + blob = fh.read() + out += [b"--" + boundary.encode(), + ('Content-Disposition: form-data; name="%s"; filename="%s"' % (k, os.path.basename(p))).encode(), + b"Content-Type: image/png", b"", blob] + out += [b"--" + boundary.encode() + b"--", b""] + return b"\r\n".join(out), "multipart/form-data; boundary=" + boundary + + +def note(g, step, data): + """A non-call record (a verdict, a comparison) saved beside the calls.""" + with open(os.path.join(g.evidence, step + ".json"), "w", encoding="utf-8") as fh: + json.dump(redact(data), fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") + + +# ---------------------------------------------------------------- scenarios + +def scenario_a(g, token): + c = g.call("A1-debug-token", "GET", "debug_token", token, query={"input_token": token}) + d = (c.body or {}).get("data", {}) if c.ok else {} + checks = {"call_ok": c.ok, "is_valid": d.get("is_valid") is True, "app_id": d.get("app_id") == APP_ID} + log("A debug_token: ok=%s is_valid=%s type=%s app_id=%s expires_at=%s data_access_expires_at=%s" + % (c.ok, d.get("is_valid"), d.get("type"), d.get("app_id"), d.get("expires_at"), + d.get("data_access_expires_at"))) + log(" scopes=%s" % d.get("scopes")) + return all(checks.values()), d + + +def scenario_b(g, token): + me = g.call("B1-me", "GET", "me", token, query={"fields": "id,name"}) + log("B /me: ok=%s id=%s name=%s" % (me.ok, me.body.get("id"), me.body.get("name"))) + acc = g.call("B2-me-accounts", "GET", "me/accounts", token, query={"fields": "id,name,tasks,access_token"}) + if not (me.ok and acc.ok): + return None + pages = acc.body.get("data", []) + log(" /me/accounts: %d page(s): %s" % (len(pages), [p.get("name") for p in pages])) + mine = [p for p in pages if p.get("name") == PAGE_NAME] + if len(mine) != 1: + log(" STOP: expected exactly one page named %s, found %d" % (PAGE_NAME, len(mine))) + return None + p = mine[0] + ptok = p.get("access_token") + if not ptok: + log(" STOP: the page entry carries no access_token") + return None + SECRETS.append(ptok) + log(" page id=%s tasks=%s (page token held in memory, %d chars)" % (p["id"], p.get("tasks"), len(ptok))) + dbg = g.call("B3-debug-page-token", "GET", "debug_token", token, query={"input_token": ptok}) + dd = dbg.body.get("data", {}) if dbg.ok else {} + log(" page token: ok=%s type=%s is_valid=%s expires_at=%s data_access_expires_at=%s scopes=%s" + % (dbg.ok, dd.get("type"), dd.get("is_valid"), dd.get("expires_at"), + dd.get("data_access_expires_at"), dd.get("scopes"))) + return {"id": p["id"], "tasks": p.get("tasks") or [], "token": ptok} + + +INSIGHT_METRICS = ("page_post_engagements", "page_follows", "page_media_view") + + +def scenario_c(g, page): + pid, ptok = page["id"], page["token"] + c1 = g.call("C1-page", "GET", pid, ptok, + query={"fields": "id,name,link,category,about,website,followers_count,fan_count"}) + log("C page fields: ok=%s %s" % (c1.ok, {k: v for k, v in c1.body.items() if k != "error"} if c1.ok else c1.err)) + c2 = g.call("C2-feed", "GET", pid + "/feed", ptok, query={"limit": "5"}) + log(" feed: ok=%s posts=%s" % (c2.ok, len(c2.body.get("data", [])) if c2.ok else c2.err)) + for m in INSIGHT_METRICS: # one call per metric: one renamed metric must not hide the others + c = g.call("C3-insights-" + m, "GET", pid + "/insights", ptok, query={"metric": m, "period": "day"}) + log(" insights %s: ok=%s %s" % (m, c.ok, "values=%d" % len(c.body.get("data", [])) if c.ok + else json.dumps(c.err, ensure_ascii=False))) + + +def hexs(s): + return (s or "").encode("utf-8").hex() + + +def prove_removed(g, step, obj_id, ptok): + c = g.call(step, "GET", obj_id, ptok, query={"fields": "id"}) + gone = (not c.ok) and isinstance(c.err, dict) and c.err.get("code") == 100 + log(" GET after DELETE %s: HTTP %s, removed=%s, error=%s" + % (obj_id, c.status, gone, json.dumps(c.err, ensure_ascii=False))) + return gone + + +def delete(g, step, obj_id, ptok): + c = g.call(step, "DELETE", obj_id, ptok) + if not (c.ok and c.body.get("success") is True): + log(" DELETE %s failed (%s) — once more after 30 s" % (obj_id, c.err)) + time.sleep(30) + c = g.call(step + "-retry", "DELETE", obj_id, ptok) + log(" DELETE %s: ok=%s body=%s" % (obj_id, c.ok, c.body)) + return c.ok and c.body.get("success") is True + + +def check_readback(label, rb, sent_hex, field, when): + got = rb.body.get(field) if rb.ok else None + res = { + "readback_ok": rb.ok, "is_published": rb.body.get("is_published") if rb.ok else None, + "scheduled_publish_time_sent": when, + "scheduled_publish_time_read": rb.body.get("scheduled_publish_time") if rb.ok else None, + "text_field": field, "sent_hex": sent_hex, "read_hex": hexs(got) if got is not None else None, + } + res["hex_equal"] = res["read_hex"] == sent_hex + log(" %s read back: is_published=%s scheduled=%s (sent %s) hex_equal=%s" + % (label, res["is_published"], res["scheduled_publish_time_read"], when, res["hex_equal"])) + return res + + +def scenario_d(g, page, rc): + pid, ptok = page["id"], page["token"] + when = int(time.time()) + WEEK_S + c = g.call("D1-create-feed", "POST", pid + "/feed", ptok, + form={"message": TEST_TEXT, "published": "false", "scheduled_publish_time": str(when)}) + if not c.ok: + log("D REFUSED: %s — stopping all writes" % json.dumps(c.err, ensure_ascii=False)) + return 6, None + post_id = c.body.get("id") + log("D created %s" % post_id) + rb = g.call("D2-readback", "GET", post_id, ptok, + query={"fields": "message,is_published,scheduled_publish_time,created_time"}) + res = check_readback("D", rb, hexs(TEST_TEXT), "message", when) + res["post_id"] = post_id + published = res["is_published"] is True + deleted = delete(g, "D3-delete", post_id, ptok) + res["delete_ok"] = deleted + res["removed"] = prove_removed(g, "D4-get-after-delete", post_id, ptok) + note(g, "D9-verdict", res) + if published: + log("D !!! the post read back PUBLISHED — deleted; removed=%s" % res["removed"]) + return 8, res + if not res["removed"]: + log("D !!! REMOVAL NOT PROVEN for %s — remove it by hand in Meta Business Suite > Planner" % post_id) + return 7, res + return rc, res + + +def scenario_e(g, page, rc): + pid, ptok = page["id"], page["token"] + when = int(time.time()) + WEEK_S + c = g.call("E1-create-photo", "POST", pid + "/photos", ptok, + form={"caption": TEST_TEXT, "published": "false", "scheduled_publish_time": str(when)}, + files={"source": LOGO}) + if not c.ok: + log("E REFUSED: %s — stopping all writes" % json.dumps(c.err, ensure_ascii=False)) + return 6, None + photo_id, post_id = c.body.get("id"), c.body.get("post_id") + log("E created photo id=%s post_id=%s (keys returned: %s)" % (photo_id, post_id, sorted(c.body))) + res = {"photo_id": photo_id, "post_id": post_id, "create_keys": sorted(c.body), "logo": os.path.relpath(LOGO, ROOT)} + if post_id: + rb = g.call("E2-readback-post", "GET", post_id, ptok, + query={"fields": "message,is_published,scheduled_publish_time,created_time"}) + res["post"] = check_readback("E post", rb, hexs(TEST_TEXT), "message", when) + if photo_id: + rp = g.call("E3-readback-photo", "GET", photo_id, ptok, query={"fields": "id,name,created_time,link"}) + got = rp.body.get("name") if rp.ok else None + res["photo"] = {"readback_ok": rp.ok, "name_hex": hexs(got) if got is not None else None} + res["photo"]["hex_equal"] = res["photo"]["name_hex"] == hexs(TEST_TEXT) + log(" E photo read back: ok=%s caption hex_equal=%s" % (rp.ok, res["photo"]["hex_equal"])) + published = bool(res.get("post", {}).get("is_published")) + # Delete the post first, then look at both ids: which object DELETE needs is a finding (§7 E). + first = post_id or photo_id + res["delete_first_target"] = "post_id" if post_id else "photo_id" + res["delete_first_ok"] = delete(g, "E4-delete-" + res["delete_first_target"], first, ptok) + res["post_removed"] = prove_removed(g, "E5-get-post-after-delete", post_id, ptok) if post_id else None + res["photo_removed"] = prove_removed(g, "E6-get-photo-after-delete", photo_id, ptok) if photo_id else None + if photo_id and post_id and not res["photo_removed"]: + log(" the photo outlived its post's DELETE — deleting the photo id too") + res["delete_photo_ok"] = delete(g, "E7-delete-photo_id", photo_id, ptok) + res["photo_removed"] = prove_removed(g, "E8-get-photo-after-delete", photo_id, ptok) + note(g, "E9-verdict", res) + removed = res["photo_removed"] is not False and res["post_removed"] is not False + if published: + log("E !!! the photo post read back PUBLISHED — deleted; removed=%s" % removed) + return 8, res + if not removed: + log("E !!! REMOVAL NOT PROVEN (photo %s, post %s) — remove by hand in Meta Business Suite > Planner" + % (photo_id, post_id)) + return 7, res + return rc, res + + +# ---------------------------------------------------------------- main + +def main(argv=None): + global VERBOSE + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("-v", action="store_true", help="print each call: method, path (no query), status, error") + ap.add_argument("--version", default="v26.0", help="Graph API version (default v26.0)") + ap.add_argument("--evidence", default=DEFAULT_EVIDENCE) + ap.add_argument("--credentials", default=os.path.expanduser("~/.config/credentials")) + ap.add_argument("cmd", choices=("read", "write-test")) + a = ap.parse_args(argv) + VERBOSE = a.v + try: + token = load_key(a.credentials) + except (CredentialError, OSError) as e: + log("KEY REFUSED [%s]: %s" % (KEY, e)) + return 2 + SECRETS.append(token) + log("key %s: %d chars, starts %s" % (KEY, len(token), token[:3])) + # write-test re-derives A and B into its own sub-directory, so the read run's files stay as they were + g = Graph(a.version, a.evidence if a.cmd == "read" else os.path.join(a.evidence, "write-test")) + try: + return run(a.cmd, g, token) + finally: + note(g, "F1-headers", g.headers_seen) # §7 F: recorded once each, on every exit path + log("F headers: %s" % json.dumps(g.headers_seen)) + + +def run(cmd, g, token): + ok_a, _ = scenario_a(g, token) + if not ok_a: + log("A FAILED") + return 3 + page = scenario_b(g, token) + if page is None: + log("B FAILED") + return 4 + if cmd == "read": + scenario_c(g, page) + log("read: done") + return 0 + if "CREATE_CONTENT" not in page["tasks"]: + log("WRITE GATE: the page's tasks lack CREATE_CONTENT (%s) — no write" % page["tasks"]) + return 5 + rc, _ = scenario_d(g, page, 0) + if rc != 0: + return rc + rc, _ = scenario_e(g, page, 0) + log("write-test: done rc=%d" % rc) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/facebook/test_fb_probe.py b/scripts/facebook/test_fb_probe.py new file mode 100644 index 00000000..589ed585 --- /dev/null +++ b/scripts/facebook/test_fb_probe.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Tests for fb_probe.py's two secret-handling seams: the key loader (R-453) and redact().""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import fb_probe # noqa: E402 +from read_credential import CredentialError # noqa: E402 + +FAKE = "EAAfakeprobe0123456789abcdef" + + +def creds(text): + fd, p = tempfile.mkstemp() + with os.fdopen(fd, "w") as fh: + fh.write(text) + return p + + +class LoadKey(unittest.TestCase): + def load(self, text): + p = creds(text) + try: + return fb_probe.load_key(p) + finally: + os.unlink(p) + + def test_single_quotes(self): + self.assertEqual(self.load("OTHER='x'\nFACEBOOK_API='%s'\n" % FAKE), FAKE) + + def test_double_quotes_and_export(self): + self.assertEqual(self.load('export FACEBOOK_API="%s"\n' % FAKE), FAKE) + + def test_trailing_whitespace_stripped(self): + self.assertEqual(self.load("FACEBOOK_API='%s' \n" % FAKE), FAKE) + + def test_mismatched_quote_refused(self): + with self.assertRaises(CredentialError): + self.load("FACEBOOK_API='%s\"\n" % FAKE) + + def test_inner_whitespace_refused(self): + with self.assertRaises(CredentialError): + self.load("FACEBOOK_API='EAA abc'\n") + + def test_not_a_meta_token_refused(self): + with self.assertRaises(CredentialError): + self.load("FACEBOOK_API='xyz123'\n") + + def test_missing_key_refused(self): + with self.assertRaises(CredentialError): + self.load("FACEBOOK_APIX='%s'\n" % FAKE) + + +class Redact(unittest.TestCase): + def test_access_token_key_dropped_everywhere(self): + out = fb_probe.redact({"data": [{"id": "1", "access_token": "zzz"}], "access_token": "y"}, secrets=[]) + self.assertEqual(out, {"data": [{"id": "1"}]}) + + def test_held_secret_and_token_shape_replaced(self): + out = fb_probe.redact({"a": "pre-sekret-post", "b": "x " + FAKE, "c": "fine"}, secrets=["sekret"]) + self.assertEqual(out, {"a": "[REDACTED]", "b": "[REDACTED]", "c": "fine"}) + + def test_hungarian_text_survives(self): + self.assertEqual(fb_probe.redact(fb_probe.TEST_TEXT, secrets=[]), fb_probe.TEST_TEXT) + + +if __name__ == "__main__": + unittest.main()