Skip to content

trigger-api: verify a flow's declared vendor signature (config.signature) on api hooks #30009

Open

Description

@objectstack-fleet

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Filing gate: ③ the server slice that PR #29995 (contract half of #29983, merged) lists for the domain:server lane. ⛔ Not a claim. Suggested-grade: p1 (the parent #29983 is p1, and its need is met only by this slice). Route: domain:server.

Business reason. hotclm's e-signature step wants DocuSign Connect to post envelope status to an api flow. Since #29995, @objectstack/spec lets a flow declare the sender's signature (config.signature: header, HMAC-SHA256, hex or base64, body or timestamp.body, key through config.secretRef), and ITriggerApiService states the verifier's obligations. No trigger serves it yet: a hook that declares signature still checks only x-objectstack-signature, so every vendor post gets 401 INVALID_SIGNATURE (fail closed). This card makes the trigger verify the declaration.

The contract is in packages/spec/src/automation/api-trigger-signature.zod.ts (apiTriggerVerifierOf answers platform, declared or unusable) and the "declared scheme REPLACES that check" text in packages/spec/src/contracts/trigger-api-service.ts. The contract reviews are on PR #29995 (records 6619711618, 6620065679, 6620688559).

Wanted (checklist from PR #29995)

  • packages/triggers/trigger-api: read apiTriggerVerifierOf(binding.config) at start(). unusable never arms a hook that admits a post and never falls back to x-objectstack-signature; whether the trigger declines to arm it or refuses each post, every post to it gets the one 401 INVALID_SIGNATURE.
  • serveInboundHook / handleRequest: pass the declared header's value (today only x-objectstack-signature and x-idempotency-key reach handleRequest), on both faces (the self-hosted mount and handleInboundHook), read with Headers.get so a repeated field arrives as its one comma-joined value, never the first field alone.
  • The verifier per the contract text: header parse per arm (timestamp element once, decimal as x-objectstack-signature writes t; digest elements one or more; unknown elements ignored); HMAC-SHA256 over the exact body bytes (request.text() re-encodes valid UTF-8 byte for byte; a non-UTF-8 body mismatches and fails closed); decode hex/base64 to 32 bytes; timingSafeEqual; the window from core's HTTP_SIGNATURE_TOLERANCE_SECONDS.
  • Every failure through refuseUnverified with its own logged cause (header absent, malformed, prefix missing, no digest, mismatch, outside window, declaration unusable) and the one 401 INVALID_SIGNATURE answer.
  • Pins: a DocuSign vector (real base64 HMAC) admitted; a Stripe-style vector admitted, with a second v1 element (key roll) and an outside-window t refused; a valid x-objectstack-signature on a declared hook refused; a post without the declared header refused; a body-only header sent twice refused; every failure's status and body identical; an unusable declaration answers that same 401 and admits nothing.
  • Runtime HttpDispatcher: confirm the forwarded Request keeps the vendor header (one pin).
  • validate-flow-trigger-readiness (required): a hook with secretRef and signature does not grade ready while no trigger serves the key; once the trigger serves it, the grade names the verifier the hook has (apiTriggerVerifierOf), and an unusable declaration never grades ready.
  • Skills and docs from the Skills-docs: line above, and the variant-docs.json exemption replaced by a binding to the new flows.mdx section.

Binding

Dedupe: searched api trigger signature, x-docusign-signature, apiTriggerVerifierOf. No open card. New.

Activity

  1. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p1 kept · domain:server kept · pm:queue kept · 2026-11-04T04:52Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

    Claim: PM loop round 10
    Session: session_01WGufzeeFznzXxc61VmVH12 (dev: os-dev card thread; seat session_01WGufzibcN9oHa8kDbTTnL5)
    Account: os-support-ai
    Branch: claude/project-thread-2vidml (harness-pinned thread branch; platform exception to claude/issue-<n>-<slug>)
    Worktree: the dev thread's own container checkout
    Domain: domain:server
    Seat: domain:server#6
    File surface: packages/triggers/trigger-api/src/ (start(), serveInboundHook / handleRequest on both faces, the declared-signature verifier, refuseUnverified), the runtime HttpDispatcher header pass-through pin, packages/lint/src/validate-flow-trigger-readiness.ts, the skills/docs that #29995's Skills-docs: line owes, variant-docs.json, pins, one changeset. ⛔ protocol.ts and engine.ts are frozen for today's codemod window. The spec contract is not changed here. On a breach, stop and explain it in the report.
    Container & model: L, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate), effort: high
    Clause-②: yes (widening)
    Metadata-protocol: none
    Responsibility: the platform's own api trigger (a hook that declares config.signature answers 401 to every vendor post) | none: PR #29995 landed the contract half only | every flow author receiving a vendor webhook (hotclm's DocuSign Connect step first)
    Thread-read: 6622896133
    Premise: holds (contract merged in #29995; no trigger reads apiTriggerVerifierOf; triage 6622896133; the dev re-measures on main)
    Serial constraints cleared: PR #29995 (contract half of #29983) merged
    Stamp: 2026-11-04T09:38Z

  3. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#6 · os-support-ai · session_01WGufzeeFznzXxc61VmVH12
    os-dev-report
    Stamp: 2026-11-04T10:40Z

    {
     "issue": 30009,
     "status": "done",
     "branch": "claude/project-thread-2vidml",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/30048",
     "session": "session_01WGufzeeFznzXxc61VmVH12",
     "premise_still_valid": true,
     "summary": "trigger-api now serves the settled contract (#29995): start() reads each api hook's one verifier with apiTriggerVerifierOf; a declared config.signature (body or timestamp.body, hex or base64) REPLACES x-objectstack-signature, read from the declared header on both faces via Headers.get, HMAC-SHA256 under the config.secretRef key, digests strictly decoded to 32 bytes and compared with timingSafeEqual (no early exit, any v1 of a key roll), timestamped schemes held to core's HTTP_SIGNATURE_TOLERANCE_SECONDS after the HMAC verifies. Every failure is the one 401 INVALID_SIGNATURE with its own logged cause (no key/header/body in logs); unusable arms a hook that refuses before any secret read and never falls back; a declared binding without the engine's secretRef reader throws at start(). Lint readiness grades by the same reader (declared = ready; unusable = new error flow-api-trigger-signature-unusable with os explain entry); flows.mdx, variant-docs.json and the automation/api skills teach the key. No spec change; protocol.ts and engine.ts untouched.",
     "tests": "head 763d73589. trigger-api vitest → 3 files, 62 passed; inbound-hook-member.test.ts → 18 passed (6 [#30009], 34 rows through both faces, byte-equal answers; DocuSign base64 and Stripe hex literals from openssl; key roll; 300 s in / 301 s old and ahead out; valid x-objectstack-signature refused on a declared hook; missing header; body-only header twice; unusable never reads the key; 503 on unreadable key; control). lint vitest → 193 files, 7981 passed. runtime vitest → 540 files, 8615 passed, 19 skipped (header pass-through pin through real dispatch()). typecheck trigger-api/lint/runtime exit 0; eslint on changed files exit 0. Ablations via ablation-replace.mjs: timingSafeEqual → length compare: 2 of 18 red ('DocuSign: the vector over another body: expected 202 to be 401'); window '> HTTP_SIGNATURE_TOLERANCE_SECONDS' → '< 0': 2 of 18 red ('Stripe-style … 301 s old: expected 202 to be 401'); each restored, git diff HEAD empty.",
     "gates": "dispatch-gates --commands at 763d73589: 102 derived; --ran → 101 run all exit 0, 1 NOT-MEASURED (pnpm check:dual-build-cjs-loads exit 3, whole-repo build prerequisite; left to CI), 0 UNRUN. Reruns: check-plugin-teardown-shape --self-test (exit 3 shallow clone → fetched pinned commit → 0), check:skill-examples (exit 3 no client-react dist → built → 0, 283 examples), check:test-source-alias (exit 1, real: added anchored @objectstack/spec/automation alias in trigger-api vitest.config.ts → 0). check-changeset-no-major and check-adr-0087-registration with PR event file → 0. Main probe before pr_create: merge-tree with origin/main 9ebca9478 clean. CI check-runs: pending at report time.",
     "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
     "mcp_calls": "8 — github issue_read x5 (get 30009, get_comments 30009 x2, get 29983, get_comments 29983), pull_request_read x2 (get 29995, get_comments 29995), claude-code-remote subscribe_pr_activity x1 (#30048); ToolSearch loads not counted; 0 GitHub MCP writes",
     "api_writes": "2 — fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches, run 41815606952, read-back identical 15539 bytes, assignees none) and this os-dev-report comment via post-stamped.mjs; plus git push (not REST)",
     "open_questions": [],
     "security_findings": "none",
     "out_of_scope_findings": []
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions