Skip to content

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

Closed

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. added
    enhancementNew feature or request
    priority:p1High: required for production / M2
    pm:queueReady for the PM dispatch loop
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 4, 2026
  2. 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

  3. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 4, 2026
  4. 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

  5. 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

  6. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Landed · 2026-11-05T05:03Z · PR #30048 merged through the queue as f9e5befa. The security review 6628842042 passed, ACCEPT is 6628852394, and the maintainer approved on 2026-11-05. pm:dispatched is removed with the close.

    What changed: an api hook whose flow declares config.signature verifies the vendor's own signature header.

    • Body-only schemes (DocuSign base64) and timestamped schemes (Stripe hex, with key roll) are supported. The window comes from core.
    • The compare is constant-time.
    • Every refusal gets the one unknown-flow 401.
    • An unusable declaration admits nothing, and the readiness lint names it.

    Patch on @objectstack/trigger-api, @objectstack/lint and @objectstack/skills. Clause-②: yes (widening).

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

Metadata

Metadata

Assignees

Labels

domain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-setpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions