Skip to content

Day 1 scaffold: three-language monorepo, core-isolation rule, envelope pipeline, CI - #1

Merged
cloakmaster merged 4 commits into
mainfrom
bootstrap/day-1
Apr 21, 2026
Merged

Day 1 scaffold: three-language monorepo, core-isolation rule, envelope pipeline, CI#1
cloakmaster merged 4 commits into
mainfrom
bootstrap/day-1

Conversation

@cloakmaster

Copy link
Copy Markdown
Owner

Summary

Day-1 deliverable per PROJECT-PLAN.md §"Day 1" and the revised plan at /Users/tester/.claude/plans/foxbook-claude-code-witty-hanrahan.md (approved before execution).

Three commits on bootstrap/day-1:

  1. feat(toolchain): pnpm + turborepo workspace, 21 TS packages, Python uv workspace (5 packages), Go 1.25 workspace (apps/log-daemon-go with non-trivial main_test.go), strict TS with path aliases, Biome 2.x, husky pre-commit.
  2. feat(rule): core-isolation checker (structural boundary + ban list applied to BOTH core/** and packages/**), anti-DeskDuck grep on source files, permanent positive + negative fixtures in __fixtures__/core-isolation/, envelope schema + type pipeline (TS via json-schema-to-typescript, Python via quicktype).
  3. feat(ci): GitHub Actions matrix (node / python / go in parallel), CODEOWNERS on rule-enforcement files, .mcp.json with GitHub + Context7 servers (project-scoped), .env.example, docs/operations/env-vars.md.

Locked decisions honoured. Structural boundary + ban list both active. packages/** is service-agnostic (closes the core → packages/shared → x402 sneak path). Negative fixtures prove the rule bites; positive fixtures prove it doesn't over-fire. Generated-types drift is enforced by pnpm check:generated in CI AND husky pre-commit. Scout wallet key management deferred to week 2 with an explicit "do NOT inject plaintext keys" note in docs/operations/env-vars.md.

ADR 0001 is deliberately a separate PR. The rule must prove itself on CI here first; the ADR then documents verified behaviour (not proposed behaviour). Do not consolidate to save time.

Test plan

CI is the validation gate — no local act pretense. Expected green on all three matrix jobs:

  • node: biome check, pnpm typecheck (21 packages), pnpm test (21 packages), check:core-isolation (fixture meta-tests + real scan), check:no-deskduck, check:generated (schema ↔ types drift)
  • python: uv sync --all-packages --frozen, uv run ruff check, uv run pytest (1 bootstrap test asserts the workspace is importable)
  • go: go vet ./..., go test ./... (asserts banner() returns non-empty)

After green + merge:

  • Enable branch protection on main: require PR, required status checks = {node, python, go}, CODEOWNERS review required, no force push.
  • Open ADR 0001 PR on bootstrap/adr-0001 (Phase 7).

Day-1 deliverable per PROJECT-PLAN.md §"Day 1".

- pnpm workspaces + turborepo at root; 21 TS packages (core, 11 adapters,
  4 apps, 5 packages) each with package.json + tsconfig.json extending
  tsconfig.base.json.
- TypeScript strict with path aliases @foxbook/core/*, @foxbook/adapters/*,
  @foxbook/packages/*.
- Biome 2.x for format + lint; husky pre-commit wired to enforce biome,
  typecheck, core-isolation, no-deskduck, and generated-types drift.
- Python via uv workspace: apps/scouts, apps/scrapers, adapters/crewai,
  packages/sdk-py, packages/types-py — src-layout, hatchling build,
  shared ruff config.
- Go 1.25 workspace for apps/log-daemon-go with main.go + main_test.go
  (the test is non-trivial so go test ./... actually exercises the
  toolchain — catches a broken CI job on day 1, not week 2).

No implementation code lands here; each package exports {} until its
feature is built.
…pipeline

Day-1 deliverable per PROJECT-PLAN.md §"Day 1".

core-isolation
- core-isolation.config.json enumerates the enforcement surface:
  serviceAgnosticZones=[core/**, packages/**], adapterZone=adapters/*,
  explicit bannedImports + bannedCapabilityLiterals (no "etc.").
- scripts/check-core-isolation.mjs enforces the structural boundary:
  service-agnostic zones cannot import adapters/* or banned identifiers,
  and cannot embed banned capability literals. Each adapters/<name>/ CAN
  and SHOULD import <name>, but cannot import sibling adapters.
- Permanent fixtures in __fixtures__/core-isolation/ prove the checker
  works: 4 negative cases (core→adapter, core→banned, core capability
  literal, packages→banned — the sneak path) and 2 positive cases
  (adapter→own-lib, core→packages). @as-if-path hints let each fixture
  be evaluated as though it lived in its target zone.

anti-DeskDuck
- scripts/check-no-deskduck.mjs rejects "DeskDuck" in source files
  (.ts/.js/.py/.go), honoring LOCKED.md §17 "no DeskDuck code reuse."
  Foundation/decision docs and CLAUDE.md are exempt — those are
  legitimate prose references.

envelope type pipeline
- schemas/envelope/v1.json holds the firehose envelope as
  envelope_version: "1.0-draft" per foxbook-foundation.md §8.1.1.
  Content freezes day 7–9 when claim flow + Merkle log stabilize;
  the pipeline exists today.
- scripts/generate-types.mjs emits packages/types-ts/src/envelope.ts
  (json-schema-to-typescript) and packages/types-py/src/foxbook_types/
  envelope.py (quicktype). Hand-written barrels re-export.
- scripts/check-generated.mjs regenerates and diffs against committed
  output — wired into CI + husky pre-commit. Schema/type drift can't
  slip past review.
Day-1 deliverable per PROJECT-PLAN.md §"Day 1".

- .github/workflows/ci.yml runs three jobs in parallel on push/PR:
    node:   pnpm install → biome check → typecheck → test →
            check:core-isolation → check:no-deskduck → check:generated
    python: uv sync → ruff → pytest
    go:     go vet → go test
  No local `act` fallback — first PR push to GitHub is the real
  validation gate for the workflow itself (act requires Docker and
  matching runner images, yak-shaving for an empty scaffold).
- CODEOWNERS gives @cloakmaster required-review on rule-enforcement
  surfaces: /core, /schemas, /adapters, /.github/workflows,
  /scripts/check-*.mjs, /core-isolation.config.json, /docs/foundation/,
  /docs/decisions/. Rule-enforcement files can't be weakened silently.
- .mcp.json (project-scoped via `claude mcp add --scope project`) wires
  GitHub MCP + Context7 MCP so the config travels with the repo.
- .env.example lists day-1 local tokens (GITHUB_PERSONAL_ACCESS_TOKEN)
  and commented-out day-2+ entries. Explicitly calls out that
  SCOUT_WALLET_PRIVATE_KEY_* must NOT be injected as plaintext — that's
  a week-2 security architecture decision.
…is canonical

pnpm/action-setup@v4 rejects the combination of an explicit 'version: 10'
step input and 'packageManager: pnpm@10.33.0' in package.json. Keep the
packageManager field (it's the upstream source of truth and affects
corepack) and let the action read from it.
@cloakmaster
cloakmaster merged commit 0eba9ec into main Apr 21, 2026
3 checks passed
@cloakmaster
cloakmaster deleted the bootstrap/day-1 branch April 21, 2026 14:20
cloakmaster added a commit that referenced this pull request Apr 21, 2026
Documents the service-agnostic core rule AFTER it was proven to bite on
PR #1. Per the two-PR pattern: scaffold first (rule lands with proof of
life via fixtures + green CI), then ADR documents verified behavior —
never proposed behavior.

Covers:
- Why the rule exists (agent-tooling flux; DeskDuck lesson carried over)
- What it enforces (structural boundary + ban list applied to both
  core/** and packages/**, closing the packages/shared sneak path)
- How it's enforced (scripts/check-core-isolation.mjs; husky + CI)
- How it's proven (permanent fixtures with @as-if-path hints)
- Alternatives considered (interface injection, runtime registry,
  Biome GritQL plugin)
- When it can be violated (only via a superseding ADR + sign-off;
  CODEOWNERS backs this)

Co-authored-by: Ben <ben@inkog.io>
cloakmaster pushed a commit that referenced this pull request Apr 30, 2026
"tried to claim a GitHub handle the caller didn't own" → "tried to
claim someone else's GitHub handle" in DM #1 and DM #3. Same content,
less stilted register. DM #2 unaffected (doesn't have this line).

Final iteration. After CI green: squash-merge per the user's pre-
authorization in the directive.
cloakmaster added a commit that referenced this pull request Apr 30, 2026
…43)

* docs(distribution): outreach roster — 10 named targets, 3 DM drafts, post-redaction

Day-8 distribution-motion PR (per the kickoff prompt). Replaces the
TBD-slot placeholders with 10 verifiable named targets researched
2026-04-28 via GitHub commit history + GraphQL discussion API +
public profile metadata. Citations per row.

Roster (10 / 10 named):
  MCP team contacts (2):
    1. David Soria Parra (@dsp-ant) — Anthropic, MCP Lead Maintainer.
    2. Den Delimarsky (@localden) — Anthropic, MCP top contributor.
  A2A spec maintainers (2):
    3. Holt Skinner (@holtskinner) — Google Cloud AI DA, top A2A
       contributor.
    4. Darrel Miller (@darrelmiller) — Microsoft, A2A TSC member,
       IETF HTTPAPI WG Chair.
  Framework authors (4):
    5. Nuno Campos (@nfcampos) — LangGraph maintainer.
    6. João Moura (@joaomdmoura) — CrewAI founder.
    7. Eric Zhu (@ekzhu) — AutoGen lead.
    8. Abhi Aiyer (@abhiaiyer91) — Mastra CTO.
  Agent-security analyst voices (2):
    9. Simon Willison (@simonw) — coined "lethal trifecta" for AI
       agent security.
   10. Kwame Nyantakyi (@Kzino) — author of IETF
       draft-nyantakyi-vaip-agent-identity-01 + A2A Discussion #1752.

Notable adjacent voice (no direct DM, engage via thread):
@kenneives — author of A2A Discussion #1734 (Composable Trust
Evidence Format, 97 comments) + AgentGraph product. Engagement
happens via reply on the public RFC thread once we file ours.

Strategic context section added: the 2026-04-28 web-research pass
surfaced four pre-existing trust/identity discussions (#1631, #1720,
#1734, #1752). Field is more crowded than PR #38's RFC text assumed.
Foxbook's positioning has to be **upstream of trust scoring**, not in
competition with it — verification primitive (cryptographic identity +
log + revocation), with reputation/scoring layering above. PR #38's
RFC text needs a Day-9 revision acknowledging #1734 + #1752 explicitly
+ naming the verification-primitive-vs-evidence-format split. Filed
in references section.

3 DM drafts (one per major lane) embedded as code blocks under the
roster, adapted from the base agent-hiring-gate framing per ADR 0006
§1 (offer-of-work-already-done, not request-for-attention):
  DM #1 (dsp-ant) — MCP angle; mentions Day-8 MCP server arriving
                    + foxbookVerifyAgentCard wrapper.
  DM #2 (holtskinner) — A2A angle; explicitly references #1734 +
                         #1752 + #1631 to position upstream-of-evidence-
                         format; mentions filed Discussion.
  DM #3 (simonw) — security-analyst angle; references his own lethal-
                   trifecta + MCP-prompt-injection writeups; mentions
                   AgentGraph + #1734 + composability boundary.

References to the adversarial-demo evidence file all use the
post-redaction filename ops/evidence/2026-04-24-identity-guard-
adversarial.md (chore/redact-third-party-handle PR #41 lands first;
this branch will rebase post-merge if needed).

Sending discipline rules + verifier_run leading-indicator definition +
references section unchanged from PR #38's outreach.md scaffold.

This PR is **drafted for review only** (per directive: "DO NOT merge
yet — open as PR for my review"). DMs will be sent through Benjamin's
accounts; the agent does not own the credentials and does not send.

* docs(outreach): backfill A2A Discussion URL + correct license claim from MIT to Apache-2.0

Discussion filed 2026-04-29 at a2aproject/A2A#1803.

Two changes in this commit:

1. Replaced two `[DISCUSSION_URL]` placeholders with the real URL:
   - DM #2 (Holt Skinner) body
   - References section

2. Corrected four occurrences of "MIT-licensed" → "Apache-2.0-licensed"
   in the DM templates. The repo's LICENSE is Apache 2.0 (committed in
   PR #45). Sending DMs to spec authors claiming the wrong license
   would have been a credibility hit caught here before send.

DMs are now ready to send. Per the send-discipline rules (§Send-
discipline rules, week-2 execution), each DM still requires hand-
personalization to the target's published work + ~5 minute
personalization cost.

* docs(outreach): replace three DMs with tightened developer-to-developer versions

Each DM cut from 215-247 words to 75-105 words. Drops:
- "the runtime-safety wrapper", "discriminated outcomes by layer",
  "structurally precluding" — pitch-shaped jargon-stack
- The closing "What blocks you from referencing this?" challenge —
  reads as cornering on a cold DM

Adds:
- Conversational close on each DM ("Worth a 5-min look?" / "what
  would you want to see different" / "what would?") — invites reply
  rather than demands justification
- Canonical URL https://transparency.foxbook.dev (was workers.dev
  preview); leaf count "7 leaves" anchors live-state
- Filed-Discussion URL a2aproject/A2A#1803
  in each DM as the specific artifact landing

Lead with concrete artifact + one piece of evidence + Discussion link
+ one inviting ask. Substance unchanged; register matches what cold
outreach to senior engineers actually looks like.

DMs ready to send once landing-page deploy is verified live in browser
(PR #51 merged but Cloudflare deploy is Benjamin-side via
`pnpm --filter @foxbook/transparency cf:deploy`).

* docs(outreach): plain-language DM rewrites — drop spec-author vocabulary

Iteration 5 on the three DMs. Substance unchanged; register shifted
from "verification primitive" / "RFC-9162-shaped" / "fetchCount === 0"
to plain English an engineer reads cold without slowing down.

Phrases dropped:
- "RFC-9162-shaped" — Certificate Transparency reference; gate-keeping
  on most readers, distracts even when known.
- "(7 leaves)" — small Merkle-tree count, reads as "barely real" to
  anyone who parses it.
- "fetchCount === 0" — code-speak in a cold DM; replaced with "the
  check refused at the source, before any Gist fetch happened" /
  "without fetching anything".
- "path-segment owner check runs before any network I/O" — three
  jargon-blocks stacked.
- "verification primitive" / "$ref" (in user-facing prose; kept in
  DM #2's specific spec ask where $ref is the actual concept being
  asked about) / "JSON-Schema interop" — spec-author vocabulary
  out of register for a cold DM.
- "Apache 2.0" inline — kept only in DM #3 where Simon Willison's
  audience tracks open-source licensing for write-ups.

Each DM now opens with what Foxbook does in plain English:
"Built a public log + one-function check that verifies whether an
agent's handle is really theirs before another agent calls it."

Closes are inviting questions, not justification-demands:
"5 min worth a look?" / "Worth a quick read?" / "what would?"

* docs(outreach): conversational micro-tweak on adversarial-test phrasing

"tried to claim a GitHub handle the caller didn't own" → "tried to
claim someone else's GitHub handle" in DM #1 and DM #3. Same content,
less stilted register. DM #2 unaffected (doesn't have this line).

Final iteration. After CI green: squash-merge per the user's pre-
authorization in the directive.

---------

Co-authored-by: Ben <ben@inkog.io>
cloakmaster added a commit that referenced this pull request May 3, 2026
…or robustness (#42)

Day-8 housekeeping for the two firehose observability findings flagged
in PR #40's bench artifact (and the Day-7 close memory):

1. **postgres-js LISTEN dispatch wedge on bad-JSON callback (Finding #1).**
   On Day-7 the listener received exactly ONE notification with a malformed
   (non-JSON) payload, the decode_error logged, and every subsequent NOTIFY
   (valid or not) was silently dropped despite the underlying TCP connection
   staying alive. Verified at the time via pg_stat_activity: connection idle,
   application_name still 'foxbook-firehose-listener', dispatch wedged at
   the postgres-js library layer. Production triggers always emit valid
   row_to_json JSON, so this isn't a runtime risk today — but a future
   migration or future event type that emits something the listener can't
   parse would silently kill the firehose with no automatic recovery.

   **Defence:** opt-in self-test heartbeat. If `selfTestIntervalMs` is set
   (default 60s; 0 disables), the listener periodically fires its own
   `pg_notify` carrying a `__listener_self_test__` event_type with a fresh
   nonce. The listener's own onnotify handler recognizes the event_type,
   filters it from the public EventEmitter (SSE clients never see it), and
   updates `lastSelfTestEchoNonce`. On the next heartbeat tick, if the prior
   ping's nonce never echoed within `selfTestIntervalMs * 1.5` tolerance,
   the listener throws → forces the reconnect-backoff path → resubscribes.
   Sentinel log:

       firehose_listener_self_test_failed observed_lag=Nms expected_within=Mms

   Tested with 3 new cases:
   - Self-test ping fires + echoes cleanly → no reconnect, public emitter
     never sees the self-test event.
   - Self-test ping fires but never echoes → tolerance window elapses →
     `firehose_listener_self_test_failed` + reconnect + resubscribe (factory
     invoked twice).
   - aliveLogEveryHeartbeats=0 disables the periodic alive sentinel
     (clean-log surface for low-noise environments).

2. **Decode-error robustness.** The decode-error path already caught
   `JSON.parse` failures + sentinel-logged. Adding the comment that bytes
   were received successfully (dispatch is healthy at the postgres-js
   layer) so future-readers understand the wedge isn't from our parse
   failure — it was from postgres-js's internal state. No behaviour change;
   documentation only.

3. **Periodic alive sentinel (Finding #2 prep).** New
   `firehose_listener_alive heartbeats=N events=M self_tests_ok=K` log
   line, fired every `aliveLogEveryHeartbeats` heartbeats (default 10 →
   ~5 min at default heartbeat). Gives operators a positive-confirmation
   signal that the listener is live + how many events have flowed without
   needing to grep for the absence of reconnect logs. Counters reset on
   each (re)subscribe so the value is per-cycle, not lifetime.

NOT included today (filed for follow-up if NOTICE-level visibility becomes
load-bearing): postgres-js `onnotice` callback wiring. The library exposes
this as a construction-time option on `postgres()`; threading it through
`createDirectPostgresClient` would require a small factory-options
extension. Doable, but `onnotice` only fires for Postgres NOTICE-level
messages (e.g. raised by RAISE NOTICE), not connection-state transitions —
which is what Finding #2 was actually asking for. The right defence for
"silent library-level reconnect" is the self-test mechanism above (it
catches the wedge regardless of what postgres-js's library-level
state is doing); the `onnotice` debug callback would be a small additional
visibility layer rather than a load-bearing fix.

Verification (all green):
  * pnpm check:core-isolation
  * pnpm -r typecheck
  * pnpm --filter @foxbook/api test → 70 passed (3 new self-test cases)

Implements the postgres-js LISTEN wedge defence + alive sentinel from
the Day-7 close memory's "Day-8 queue".

Co-authored-by: Ben <ben@inkog.io>
cloakmaster added a commit that referenced this pull request Jun 2, 2026
…93) (#94)

* chore(lint): clear biome warnings + remove dead code, gate CI on warnings

Drive the biome warning count to zero without changing behavior:

- Export base64urlDecode from @foxbook/core and drop the byte-identical
  duplicate copy in apps/api/src/claim/handlers.ts (import from core).
- Remove 2 dead biome-ignore comments in validators capability.test.ts
  and the dead eslint-disable in apps/api/src/firehose/listener.ts (the
  repo has no eslint, and the loop condition reads `stopped` so it was
  never a constant condition).
- Scope lint/style/noNonNullAssertion off for TEST globs only (so test
  indexing bangs stop warning) — kept on for src, fixing the real src
  non-null assertions (sha256, jws, claim/repository, merkle-repository,
  smoke-test-revoke) with bounded `?? 0` / destructure-guard rewrites.
- Apply the one safe useTemplate fix in claim.repository.test.ts.
- Add --error-on-warnings to the biome step in CI so the count can't
  silently creep back up.

Verification: `pnpm biome check --error-on-warnings` (clean, exit 0),
`pnpm typecheck` (24/24), `pnpm --filter @foxbook/api test` (86 pass),
`pnpm --filter @foxbook/core test` (59 pass), validators (72 pass),
plus core-isolation / no-deskduck / generated pre-commit checks.

* docs: fix doc inaccuracies (verifyAgentCard arity, firehose default, env vars, ADR count, 0007 header)

- README hero + RFC + did:foxbook samples: add required asset_type to verifyAgentCard options
- README: move firehose out of Live; it is disabled by default since #84 (FOXBOOK_FIREHOSE_ENABLED)
- env-vars.md + .env.example: document FOXBOOK_FIREHOSE_ENABLED; .env.example adds DATABASE_URL_DIRECT + FOXBOOK_LOG_SIGNING_KEY_HEX, drops stale VERCEL_TOKEN/SENTRY_AUTH_TOKEN + Day-N prose
- RATIONALE.md: ADR count eight->nine, add ADR 0009 line, fix misattributed 60s cache-policy sentence (API dynamic-state, not all read endpoints)
- STATE-AT-STABLE-MODE-CLOSE.md: fix truncated Discussion #73 link
- ADR 0007: add standard metadata block to match other ADRs

* docs(spec): reconcile did:foxbook method spec to shipped code

The did:foxbook method spec advertised behaviour the reference
deployment does not implement. Filing to W3C as-is would describe a
method that cannot be resolved. Downgrade the spec to describe what
actually ships (no new endpoints — ADR 0008 freeze):

- §1.1/§4.4/§6.2/§9: the signing path uses insertion-order canonical
  JSON (JSON.stringify semantics with caller-fixed key order; ADR 0005,
  core/src/crypto/canonical.ts, packages/db/src/merkle-repository.ts),
  NOT RFC 8785 JCS. The JCS canonicalizer in core/src/crypto/jcs.ts is a
  separate interop-only path, not used to sign leaves/STHs/JWS. Move
  RFC 8785 from Normative to Informative. Mirror the same fix in
  docs/specs/W3C-REGISTRY-SUBMISSION.md.
- §3.2/§3.2.4/§7.1: there is no GET /agents/<did> resolver and no
  /.well-known/jwks.json. Rewrite resolution as a client-side projection
  over the real endpoints: /api/v1/claim/by-handle/...,
  /leaf/<index>, /inclusion/<index>, /root, and
  /.well-known/foxbook.json (where log_signing_public_key_hex lives).
- §4.2: consistency proofs are GET /consistency?old=N&new=M (query
  params), not /consistency/<from>/<to> path segments.
- §3.3/§3.4: rewrite the rotation and revocation leaf examples to match
  schemas/tl-leaf.v1.json exactly (signing-key-registration uses
  prior_ed25519_public_key_hex + new_ed25519_public_key_hex +
  recovery_key_signature + published_at, dropping previous_leaf_index;
  revocation uses revoked_key_hex + recovery_key_signature +
  revocation_timestamp + reason_code enum).
- §2.1: relax the MSI ABNF/regex to ^did:foxbook:[0-9A-HJKMNP-TV-Z]{26}$
  to match core/src/did.ts (the code does not enforce the [0-7]
  leading-character restriction). Fix the same regex in the W3C runbook.

Docs only; no code change.

* ops(uptime): split Neon-touching probes to */30 + document autosuspend as the #1 free-tier survival setting

Reduce how often the uptime monitor wakes the free-tier Neon compute
while keeping fast detection of a hard outage.

uptime.yml: add a second cron so the static landing (no DB) keeps its
*/15 cadence while the two DB-backed probes (api/healthz,
transparency/root) ride a slower */30 cron — roughly halving
monitor-induced Neon wakeups. Each matrix entry is tagged with the
cron it runs on; the probe step is gated via github.event.schedule so
the off-schedule endpoints are skipped (and workflow_dispatch still
probes everything). The 'Fail run if endpoint is down' behavior is
unchanged: a skipped probe yields an empty outcome, so the fail step
simply doesn't fire.

OPERATIONS.md: in § 'Neon compute-hour exhaustion', make enabling Neon
autosuspend / scale-to-zero the prominent #1 action (the Fly /health
and uptime changes only reduce wakeups; they do nothing if the compute
can never idle), add a one-line 'how to check current usage' pointer
to the compute-hours graph, and restate that Launch ($19/mo) removes
the cap entirely. Update the 'Uptime monitoring' runbook to describe
the split schedule and the detection-latency tradeoff.

* chore(ci): harden supply chain — dependabot, SHA-pinned actions, prod prune

- add .github/dependabot.yml: weekly grouped minor/patch updates for npm
  (root), pip (pyproject), gomod (apps/log-daemon-go), and github-actions.
- pin every workflow `uses:` to a 40-char commit SHA with a trailing
  # vX.Y.Z comment. superfly/flyctl-actions/setup-flyctl was on @master in
  the token-bearing deploy job — now pinned to ed8efb3 (v1.6).
- add `permissions: { contents: read }` to deploy-api.yml (least privilege
  for the tag-gated Fly deploy).
- bump vulnerable deps: hono ^4.12.23 (api + transparency), turbo ^2.9.16.
- apps/api/Dockerfile: `pnpm prune --prod` after install to drop
  devDependencies from the prod image. tsx moved to @foxbook/api
  dependencies so it survives the prune — the container boots with
  `node --import tsx ./src/main.ts` and needs it at runtime. Guarded by
  a new prod-runtime-deps test.
- ci.yml: non-blocking SCA step (`pnpm audit --audit-level=high || true`).

* test(hardening): de-flake firehose sleeps + tier/consistency correctness fixes

Mechanical test hardening plus two small correctness fixes (each TDD'd).

Test hardening:
- firehose.listener.test.ts: replace the three fixed setTimeout sleeps
  (the reconnect-sentinel and self-test-ping positive waits, and the
  alive-sentinel negative wait) with the existing poll-until pattern;
  the negative no-event assertion now uses fake timers so absence is
  deterministic. These fixed waits caused CI flakes in #62 and #64.
- sdk-claim: add a drift guard (merkle-parity.test.ts) that runs every
  schemas/merkle-test-vectors.json inclusion vector through the inlined
  verifyInclusion (merkle-internal.ts), pinning it in lockstep with
  @foxbook/core (the vectors are core-generated ground truth).

Correctness fixes:
- core verifyConsistency: the m===0 branch accepted ANY oldRoot
  vacuously; now enforce bytesEqual(oldRoot, EMPTY_TREE_ROOT) so a
  forged zero-leaf root can't "prove" consistency. Added a negative
  vector.
- claim/by-handle: tier2_pending (an unverified domain claim awaiting
  DNS/endpoint proof, app-state-only with no Merkle leaf) reported
  verification_tier 1; map it to 0. The tier>=1 leaf lookup now
  correctly skips it. Added handler + response-shape tests.

Tooling:
- add a .husky/pre-push hook running `pnpm test` (turbo-cached unit
  suite) so a red branch never leaves the machine.

* fix(adapter-endpoint-challenge): SSRF guard on outbound fetch + per-IP claim rate limit

The Tier-2 endpoint-challenge adapter fetched a claimant-supplied URL
(apps/api/src/claim/body-schema.ts accepts endpoint_url) with no scheme
or host validation. Reachable via two unauthenticated POSTs
(/claim/start-domain then /claim/verify-endpoint), it was an SSRF
primitive: point endpoint_url at cloud metadata (169.254.169.254),
loopback, or RFC-1918/ULA internal services and read the JSON we
round-trip.

SSRF guard (two layers, sharing one isBlockedIp policy):
  1. Pre-flight (guard.ts: assertOutboundAllowed) before any socket —
     https-only, then resolve the hostname and reject if ANY candidate
     IP is private/loopback/link-local/ULA/metadata
     (0.0.0.0, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1, ::,
     fc00::/7, fe80::/10, incl. IPv4-mapped v6). Bare IP literals are
     classified directly with no DNS. Fail-closed on resolver error.
  2. Connect-time pin (node-transport.ts) — the default transport drives
     the request through node:https with a guarded dns.lookup that
     re-applies isBlockedIp to the exact address the socket is about to
     dial, closing the DNS-rebinding window between pre-flight and
     connect. Handles the all:true array shape so a private record in a
     multi-A response can't slip through.
Redirects: redirect:"manual" + reject every 3xx (a Location can re-open
the hole the pre-flight closed; a real challenge endpoint returns the
JWS directly).

The adapter's existing interface is unchanged; new optional
EndpointVerifyOptions.resolveHostname lets tests drive the pre-flight
deterministically, and the existing fetch seam is preserved.

Rate limit (apps/api/src/claim/rate-limit.ts): per-IP in-memory token
bucket on the mutating claim POST routes (keyed off Fly-Client-IP /
X-Forwarded-For), 429 + Retry-After when empty. Blunts the
amplification/abuse the outbound fetch enables. Per-instance by design
(documented) — a coarse safety valve, not a distributed quota. The
read-only GET /claim/by-handle is intentionally excluded.

Tests: private/loopback/metadata + non-https targets rejected before any
fetch; redirect to a private host blocked; connect-time guard blocks a
loopback resolution (rebind defence); normal public https still
round-trips (resolver/fetch mocked). Rate-limit: bucket math, per-IP
isolation, refill, and the route 429 boundary.

* docs(rationale): drop /api/v1/discover from Cache-Control list (sets none)

* docs(ops): Neon autosuspend step references the 'main' branch (matches restore runbook)

---------

Co-authored-by: Ben <ben@inkog.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants