Skip to content

feat(endpoints): access modes, named bot checks, forms (#2259 #2176) - #2330

Merged
cjimti merged 1 commit into
mainfrom
feat/2259-endpoint-access-modes
Oct 26, 2026
Merged

cjimti merged 1 commit into
mainfrom
feat/2259-endpoint-access-modes

Conversation

@cjimti

@cjimti cjimti commented Oct 26, 2026

Copy link
Copy Markdown
Member

Changes manage_script endpoint_set, /endpoints/{name}, /botcheck/challenge, /api/v1/admin/bot-checks (replacing /api/v1/admin/settings/bot-check), /api/v1/admin/endpoints, /api/v1/portal/scripts and /api/v1/portal/scripts/{id}/versions/{version}/graph, adds migration 000221, and fixes 3 bugs found while building it.

Closes #2259
Closes #2176
Closes #2314
Closes #2315

Access modes (#2259)

Every endpoint has exactly one access mode, set on its binding (internal/endpoint/epbinding):

Mode Admits Portal label
authenticated A platform API key or a browser session the binding's callers names: *, persona:<name>, api_key:<name>. Empty admits no one but the administrator, who is always admitted Authenticated (API key or session)
public Anyone, with no check Public
bot_check Anyone who passes the named bot check, on the binding's checked_methods (empty for every method; HEAD is checked where GET is) Bot check: <name>
  • signed_in, open_reads, the provider none and allow_public_without_check are gone, with no alias. manage_script's schema enumerates the three modes, and the portal route refuses signed_in with 400.
  • A caller the list does not name gets 403 before the handler runs, and the refusal is counted under the new reason caller (internal/endpoint/eprefusal).
  • The caller identity now carries the API key's name (middleware.UserInfo.APIKeyName, through portal.User), so api_key:<name> matches a key bound to a person as well as a standalone one. The persona comes from the registry's role resolution, and administrator status from Registry.AdminRule, the rule every other surface uses (internal/httpserver/endpointwire).
  • The persona access report lists the authenticated endpoints whose callers name the persona. In place of the old "signed-in endpoints, External included" line, it now counts the endpoints open to every caller and the ones open to anyone. The old line was wrong: External personas reach no endpoint.

Named bot checks

The deployment-wide platform_settings.bot_check section is replaced by the bot_checks table (internal/botcheck/checks.go, checkstore.go). Each check has a name, a provider (pow, turnstile, hcaptcha) and that provider's settings.

  • script_endpoints.bot_check references bot_checks(name) ON DELETE RESTRICT, so the database refuses deleting a check an endpoint names. The API answers 409 with those endpoints.
  • A proof-of-work challenge's signed claims carry the check name, so an endpoint naming another check refuses the answer ("issued by another bot check"). A provider token is verified with the secret key of the check the endpoint names.
  • Binding an endpoint to a check that does not exist is refused, and so is binding one to a proof-of-work check on a deployment with no signing key.
  • Admin routes: GET /api/v1/admin/bot-checks (each check with used_by and usable, plus key_available), GET|PUT|DELETE /api/v1/admin/bot-checks/{name}.
  • Admin > Settings > Bot checks lists the checks with their provider and the endpoints using them. A row opens an editor, and Delete is disabled while an endpoint names the check.
  • The docs say what each provider stops: proof of work is a per-request cost any program can pay; Turnstile and hCaptcha need a browser; an automated caller belongs on an authenticated endpoint with an API key.

Migration 000221

  • Creates bot_checks and builds default from the old section. A section that was never written, or was set to none, becomes proof of work at its defaults.
  • signed_in becomes authenticated with callers {*}, which admits whom it admitted before.
  • A public binding that answered with no check becomes public: one with open reads that answered only GET and HEAD, and every public binding where the section was none with the override.
  • Every other public binding becomes bot_check naming default, checking every method, or every method but GET and HEAD where its reads were open.
  • Where the section was none without the override, those endpoints answered nothing; they become bot_check: default and are turned off.
  • Adds caller to the script_endpoint_refusals.reason CHECK.
  • The down migration restores the previous shapes.

On the dev database, which held bindings from earlier runs, the migration rewrote 158 signed_in bindings to authenticated {*} and 76 public bindings to bot_check default.

Forms (#2176)

A bound script that defines form_spec(req) and form_submit(req, values), in place of handle(req), is a form (internal/endpoint/epform, internal/endpoint/eprun/form.go).

  • GET calls form_spec(req). The platform checks the spec: field types text, long_text, email, number, date, single_choice, multiple_choice, checkbox and hidden; unique names; choices only on choice fields; limits. It answers {spec, form_token, token_field, honeypot_field}.
  • The token signs the whole spec with a key derived from the bot check key (label endpoint-form-v1). It is bound to the endpoint and valid for 24 hours, and no copy is kept, so a submission is checked on whichever replica receives it.
  • A submission (urlencoded or a JSON object, read the same way) is checked before the script runs:
    • 400 for a missing, altered, foreign or expired token, a filled website honeypot, or a submission sooner than min_fill_seconds after the GET (at least 2; a spec may raise it);
    • 422 {"errors": {field: message}} for an undeclared field, a missing required one, a wrong type, a value past its limit, or a hidden field that differs from its prefill.
  • form_submit(req, values) gets every declared field, typed. It returns None to accept with the spec's confirmation and redirect, {"errors": {...}} to refuse field by field, or {"confirmation", "redirect"} to accept with its own.
  • Elsewhere: a form script is not a library (scriptlib); the entry-point lint accepts the two handlers with their fixed parameters (scriptlint); epbinding.Callable accepts a form; and manage_script help documents the contract with a worked example.
  • Not in this PR: storing submissions is feat(webhooks): a browser-submission source for each form #2178, and rendering forms in assets is feat(portal): render forms and embed them in assets #2177.

Endpoints in the Automations list (#2314)

GET /api/v1/portal/scripts rows carry endpoint (name, address, access mode, whether it is on), read in one call per page (scriptlist.Endpoints). endpoint=bound|unbound narrows the listing in the store, ahead of the page cap. The list has an Endpoint column (the address, with Off when the binding is off) and an Endpoint filter (All, Bound, Not bound).

Flow for endpoint scripts (#2315)

The flow walk used to add only the platform's call of main(), so an endpoint script drew an empty graph. It now enters through handle(req) when there is no main(), and through form_spec(req) then form_submit(req, values) for a form (internal/platform/scriptflow/survey.go). A script that defines main() draws as before.

Bugs fixed

  1. Migration 000207's CHECK on script_endpoint_refusals.reason did not name caller. A replica adds all its counts in one statement and puts a refused batch back to retry, so one caller count would have held back every endpoint's refusal counts on that replica. 000221 adds the reason to the constraint, and eprefusal.Counter now drops any reason eprefusal.Reasons does not list. TestRealDB_EveryRefusalReasonIsStored writes a count under every listed reason.
  2. A form script, which has neither main() nor handle(), was judged a library, and the unused-parameter lint refused form_submit(req, values) when req went unread. The parameters of handle, form_spec and form_submit are now the platform's to pass.
  3. Admin > Settings > Bot checks built a new check's blank value on every render, so a background refetch reset what had been typed into it.

Readers of this state

make state-readers-check listed the changed API contracts. Readers updated in ui/src:

  • Bot checks: api/admin/types/botCheck.ts, api/admin/hooks/botCheck.ts, pages/settings/BotChecksSettingsCard.tsx (replacing BotCheckSettingsCard.tsx), settingsSections.ts, AdminSettingsPage.tsx, the MSW handlers in mocks/handlers/storage.ts, and the screenshot route admin-settings-bot-checks.
  • Endpoint admin rows (callers, bot_check, bot_check_provider, checked_methods, open_reads removed): api/admin/types/endpoints.ts, pages/scripts/endpoints/EndpointsTab.tsx, EndpointDetailPage.tsx, endpointLabels.ts, mocks/data/endpoints.ts.
  • Script binding (auth_mode, callers, bot_check, checked_methods): api/portal/hooks/scriptEndpoint.ts, pages/scripts/ScriptEndpointCard.tsx, mocks/handlers/scripts.ts.
  • Listing rows (endpoint) and endpoint=: api/portal/hooks/scriptListFilter.ts, scripts.ts, pages/scripts/ScriptListing.tsx, ScriptRow.tsx, ScriptGrid.tsx.
  • Persona access (references.endpoints, unscoped.any_caller_endpoints, unscoped.anyone_endpoints, signed_in_endpoints removed): api/admin/types/personaAccess.ts, pages/settings/persona/AccessSummary.tsx, mocks/handlers/personaAccess.ts.

New import edges

  • internal/admin/endpointapi -> internal/botcheck: the endpoint list shows each bot_check endpoint's provider.
  • internal/httpserver/scripthttp/scriptlist -> internal/endpoint/epbinding: the listing reads its rows' bindings.
  • internal/endpoint/epbinding -> internal/endpoint/epform: Callable accepts a form.
  • internal/endpoint/eprun -> internal/endpoint/epform and internal/endpoint/epwire -> internal/endpoint/epform: the runner serves forms, and the wiring derives their key.
  • internal/endpoint/epform -> internal/platform/scriptdialect, internal/platform/starlarkconv, pkg/portal/shareguest: parsing the handlers, reading what form_spec returns, and deriving the token key, as internal/botcheck derives its key.

internal/botcheck -> internal/platform/settingsjson is removed.

Acceptance

Run through the real surface on the local dev stack (two replicas behind the proxy, Trino connection acme), with transcripts at build/<n>/acceptance.md:

Cloudflare's published Turnstile test key verifies every token, so the suite shows a proof-of-work endpoint refusing a Turnstile token, and two Turnstile checks with different keys answering one token differently. It does not show a Turnstile endpoint refusing a proof-of-work answer; that rests on the provider refusing a token its key did not issue.

The migration's per-binding mappings, including the round trip, are covered by TestMigration_*_RealDB (internal/botcheck) against PostgreSQL, and make migrate-check passed on pg16 and pg17.

Changes manage_script endpoint_set, /endpoints/{name}, /botcheck/challenge, /api/v1/admin/bot-checks (replacing /api/v1/admin/settings/bot-check), /api/v1/admin/endpoints, /api/v1/portal/scripts and the script graph route, with migration 000221, and fixes 3 bugs found on the way.

Endpoints have one access mode each: authenticated (a platform API key or browser session among the binding's callers, the administrator always admitted), public (no check) or bot_check (a named check, on its checked_methods). Bot checks are a list of named checks; one an endpoint names cannot be deleted. Migration 000221 rewrites signed_in to authenticated with callers ["*"] and public to bot_check naming "default".

A bound script defining form_spec(req) and form_submit(req, values) is a form: GET answers the spec signed into a form_token, and a submission is checked against that spec (fields, types, limits, honeypot, minimum fill time) before form_submit runs.

The Automations list shows each script's endpoint and filters by endpoint=bound|unbound. The flow diagram draws handle(req), and a form's two handlers.

Bugs fixed:
1. script_endpoint_refusals' CHECK did not name the new caller reason, and a refused batch is retried whole, so one such count would have held back every endpoint's counts on a replica.
2. A form script was judged a library, and the unused-parameter lint refused a request handler's req.
3. Admin > Settings > Bot checks reset a new check's draft on a background refetch.

Closes #2259
Closes #2176
Closes #2314
Closes #2315
@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.17347% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.79%. Comparing base (96a6438) to head (d3bcc3a).

Files with missing lines Patch % Lines
internal/httpserver/endpointwire/endpointwire.go 44.44% 10 Missing ⚠️
internal/endpoint/eprun/form.go 92.00% 6 Missing ⚠️
internal/endpoint/epform/epform.go 96.59% 3 Missing ⚠️
internal/admin/botcheckapi/botcheckapi.go 96.22% 2 Missing ⚠️
internal/botcheck/checkstore.go 97.01% 2 Missing ⚠️
internal/endpoint/epform/check.go 98.31% 2 Missing ⚠️
internal/endpoint/epform/token.go 93.33% 2 Missing ⚠️
internal/admin/endpointapi/endpointapi.go 95.45% 1 Missing ⚠️
internal/botcheck/checker.go 96.42% 1 Missing ⚠️
internal/botcheck/checks.go 97.77% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2330      +/-   ##
==========================================
- Coverage   94.79%   94.79%   -0.01%     
==========================================
  Files        1417     1422       +5     
  Lines      115071   115639     +568     
==========================================
+ Hits       109087   109624     +537     
- Misses       5965     5995      +30     
- Partials       19       20       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cjimti
cjimti merged commit 5d35d71 into main Oct 26, 2026
14 checks passed
@cjimti
cjimti deleted the feat/2259-endpoint-access-modes branch October 26, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant