Spike: Facebook Page access probe — key valid and non-expiring, reaches no Page (R-914, R-915 opened)
gates / gates (push) Successful in 4m34s

scripts/facebook/fb_probe.py (stdlib, read + write-test, no real-post command) with tests; read run twice:
SYSTEM_USER, expires_at 0, /me/accounts empty, so D/E did not run and nothing was posted. Findings, redacted
evidence, CONTEXT decision home, STATUS item, scripts CHANGELOG, REPORT-facebook-page-api.md.
This commit is contained in:
2026-10-08 18:03:27 +02:00
parent fe0dc0d03f
commit ff276ed7d9
12 changed files with 765 additions and 2 deletions
+9
View File
@@ -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
+50
View File
@@ -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).
+10 -1
View File
@@ -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
@@ -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.
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+3 -1
View File
@@ -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)
+8
View File
@@ -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
+424
View File
@@ -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())
+71
View File
@@ -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()