Skip to content

fix(spec): require an explicit spec source; drop the production default - #7

Merged
AminDhouib merged 2 commits into
mainfrom
fix/sync-spec-no-production-default
Sep 2, 2026
Merged

AminDhouib merged 2 commits into
mainfrom
fix/sync-spec-no-production-default

Conversation

@AminDhouib

@AminDhouib AminDhouib commented Sep 1, 2026

Copy link
Copy Markdown
Member

What was wrong

pnpm sync-spec and pnpm check-spec-drift both resolved their source like this:

const SPEC_URL = process.env.SENDLY_OPENAPI_URL ?? "https://api.sendly.now/api/openapi.json";

That default is production.

Why production is banned, not merely unwise. The vendored openapi.json is a contract snapshot: it is the input to pnpm build:types, and the thing src/__tests__/contract.test.ts asserts the SDK surface against. Production serves whatever happens to be deployed at the instant you run the script — mid-deploy states, hotfixes, work that has not been reviewed and may be rolled back. A snapshot taken from it is unreproducible (two maintainers running the same command on the same commit can get different files) and unreviewable (the diff cannot be traced to a merged change). The committed contract in the platform monorepo is reviewed, versioned, and diffable; production is a moving target that happens to answer on port 443.

The practical consequence was worse than a bad default:

  • The banned call was not merely available, it was automated. .github/workflows/ci.yml ran pnpm check-spec-drift unconfigured on every push to main, every pull request, and a weekly cron — so this public repo fetched production on a schedule, unattended.
  • The safe source was unreachable. Node's fetch() rejects non-http(s) schemes, so SENDLY_OPENAPI_URL pointing at a local file — bare path or file:// — died with an uncaught TypeError: fetch failed and a raw stack trace. The only source either script could actually consume was an http(s) URL, and the only one configured was production.

So the mechanism that would keep the spec current was the one nobody was allowed to run, which is why it drifted.

What changed

SENDLY_OPENAPI_URL is now required, with no default, and accepts three forms:

Form Example
filesystem path (normal case) /path/to/sendly/apps/web/openapi/openapi.json
file:// URL file:///path/to/.../openapi.json
http(s):// URL (local/staging) http://127.0.0.1:8931/openapi.json

New scripts/spec-source.mjs holds the resolver and the unconfigured message, so the two scripts cannot drift apart on where the spec comes from or how they report a missing source.

The two scripts differ deliberately when unconfigured:

  • sync-spec (writes) fails hard, exit 1, printing what to set. It is the mechanism this guardrail is about.
  • check-spec-drift (advisory, never fails) skips with a notice, exit 0. It runs unattended in CI on every pull request including forks, which cannot supply a source; turning that red would report a configuration gap as if it were spec drift.

ci.yml now passes ${{ vars.SENDLY_OPENAPI_URL }} to the drift step. Until a maintainer sets that repository variable to a non-production contract, the drift step and the weekly cron are inert by design — which is the intended state, and better than the previous behaviour of silently probing production. The comments say so.

README.md gains a "Refreshing openapi.json" section with the correct invocation.

What is deliberately NOT in this PR

No spec re-sync and no regenerated types. openapi.json and src/types.generated.ts are untouched. This PR fixes the tap, not the tank.

The re-sync is queued behind two in-flight platform packages — WP6 (API-key create/rotate, domain setup handoff, the revealUrl response field) and WP9 (mailbox tools). Re-syncing now would close today's gap and immediately open a new one.

That also means the known scope bug stays open a little longer, on purpose: src/types.generated.ts documents POST /api/v1/campaigns/{id}/send as "Requires the campaigns:write scope", but the route has enforced campaigns:send since the scope split. A customer who mints a narrow key from the SDK's own JSDoc gets a 403. Legacy full-access keys are grandfathered, so this bites only users who did the careful thing. It is fixed by regenerating from a current contract — which this PR makes possible and a follow-up will do.

For reference, running the drift check against the current contract reports the SDK spec is now 8 operations behind (not the 5 recorded earlier — WP6's spec regeneration has since landed):

operations only in source: DELETE /api/projects/{id}/api-keys/{keyId}, GET /api/projects/{id}/api-keys,
  GET /api/v1/projects, POST /api/domains/{id}/dodomain-session, POST /api/projects/{id}/api-keys,
  POST /api/projects/{id}/api-keys/{keyId}/rotate, POST /api/users/me/projects, POST /api/v1/emails
schemas only in source: ApiKey, ApiKeyListResponse, CreateApiKeyBody, EmailV1, FilterConditionV1,
  FilterGroupV1, ProjectRecord, ProjectV1, SegmentFilterV1, SendEmailV1

Verification

Run, not inferred from the diff.

Unconfigured — sync-spec fails loudly:

$ node scripts/sync-spec.mjs
sync-spec: SENDLY_OPENAPI_URL is not set, and there is no default.

Point it at the committed contract in the Sendly platform monorepo:
  SENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json pnpm sync-spec

An http(s):// URL of a local or staging API works too. Do NOT point it at
production (https://api.sendly.now): the SDK spec is synced from the committed
contract, never live-synced from the deployed API.
exit=1

openapi.json was not touched.

Unconfigured — check-spec-drift skips, stays green:

$ node scripts/check-spec-drift.mjs
spec drift check: skipped — SENDLY_OPENAPI_URL is not set. Set it to the committed contract (apps/web/openapi/openapi.json in the platform monorepo) to compare.
exit=0

Configured — the legitimate invocation still works, all three forms:

$ SENDLY_OPENAPI_URL=<path>/apps/web/openapi/openapi.json node scripts/sync-spec.mjs
sync-spec: wrote openapi.json (OpenAPI 3.1.0, 52 paths) from <path>/apps/web/openapi/openapi.json
exit=0

$ SENDLY_OPENAPI_URL=file:///<path>/apps/web/openapi/openapi.json node scripts/sync-spec.mjs
sync-spec: wrote openapi.json (OpenAPI 3.1.0, 52 paths) from <path>\apps\web\openapi\openapi.json
exit=0

$ SENDLY_OPENAPI_URL=http://127.0.0.1:8931/openapi.json node scripts/sync-spec.mjs
sync-spec: wrote openapi.json (OpenAPI 3.1.0, 52 paths) from http://127.0.0.1:8931/openapi.json
exit=0

All three were reverted with git checkout -- openapi.json; the committed spec in this PR is unchanged.

Repo gates: pnpm format:check (clean), pnpm lint (no issues), pnpm check-types (clean), pnpm test (18 files, 140 tests passed).

Companion

DevinoSolutions/sendly-python gets the same change on the same branch name, with the same message text and the same write-fails / check-skips split, so the two SDKs do not fail differently for the same missing configuration.


Update: production is loud, not blocked (review follow-up)

Production is not hard-blocked — "what does production actually serve right now?" is a legitimate one-off, and refusing it would break that. What must not happen is it happening quietly, which is exactly how the old default ran unnoticed on every push, every PR and a weekly cron. So the fix is volume:

!!!===========================================================================!!!
!!!  WARNING: reading the OpenAPI spec from PRODUCTION                        !!!
!!!  https://api.sendly.now/api/openapi.json
!!!                                                                          !!!
!!!  This is the BANNED path. Vendoring a spec from the deployed API makes    !!!
!!!  the SDK mirror what is RUNNING instead of what the repo DECLARES, which  !!!
!!!  launders code-vs-contract drift into 'correct' and destroys the SDK's    !!!
!!!  ability to detect the very drift it exists to catch.                     !!!
!!!                                                                          !!!
!!!  Only ever do this as a DELIBERATE one-off (e.g. 'what does production    !!!
!!!  actually serve right now?'). NEVER commit the result, and never wire     !!!
!!!  this host into CI or any unattended job.                                 !!!
!!!===========================================================================!!!

Under GITHUB_ACTIONS it additionally emits a ::warning annotation, so pointing the repository variable at production would surface on the run itself rather than hiding in a log. Wired into both sync-spec and check-spec-drift — the latter matters most, since that is the step that runs unattended.

The scripts also now record why production is banned, not merely that it is. A bare guardrail with no stated reason reads as superstition to the next maintainer and gets deleted. The reason: vendoring from the deployed API makes the SDK mirror what is running rather than what the repo declares, so code-vs-contract drift is laundered into "correct" on the way in — and the vendored spec, whose entire job is to be the fixed reference contract.test.ts compares against, loses the ability to detect the drift it exists to catch.

Verified: the banner fires for api.sendly.now, and does not fire for a local path or a localhost URL. Both CI runs on this branch show zero production-warning lines and the drift step still skipping. Gates re-run green.

`pnpm sync-spec` and `pnpm check-spec-drift` both defaulted to
https://api.sendly.now/api/openapi.json. Syncing the vendored SDK spec from
the deployed API is forbidden by platform policy, which left the only
mechanism for keeping openapi.json current as one nobody is allowed to run.

SENDLY_OPENAPI_URL is now required and has no default. It accepts a filesystem
path (the normal case: apps/web/openapi/openapi.json in the platform monorepo),
a file:// URL, or an http(s):// URL. Neither script could read a local file
before -- Node's fetch() rejects non-http(s) schemes -- so the committed
contract was not merely a non-default source, it was unreachable.

- scripts/spec-source.mjs: shared resolver, so the two scripts cannot disagree
  about the source or report a missing one differently.
- sync-spec (writes): unconfigured is a hard error naming what to set.
- check-spec-drift (advisory, non-blocking): unconfigured SKIPS with a notice.
  It runs unattended on every PR including forks, which cannot supply a source;
  failing there would report a configuration gap as spec drift.
- ci.yml: the drift step now reads the SENDLY_OPENAPI_URL repository variable
  instead of reaching for production on every push, PR and weekly cron.

Does NOT re-sync the spec or regenerate types -- see the PR description.
…is banned

Review follow-up. Two additions, no reversal of the earlier decision not to
hard-block production.

1. warnIfProduction(): when the resolved source resolves to api.sendly.now,
   print an unmissable banner to stderr and, under GITHUB_ACTIONS, emit a
   ::warning annotation. It does NOT refuse -- "what does production actually
   serve right now?" is a legitimate one-off. Quiet is the property that made
   the old default dangerous, not the host, so the fix is volume, not a block.
   Wired into both sync-spec and check-spec-drift; the latter matters most,
   since that is the step that runs unattended in CI.

2. The scripts now record WHY production is banned, not merely that it is:
   vendoring the spec from the deployed API makes the SDK mirror what is
   RUNNING rather than what the repo DECLARES, laundering code-vs-contract
   drift into "correct" and destroying the vendored spec's ability to detect
   the very drift it exists to catch. Without the reason written down, a future
   maintainer reads the guardrail as superstition and deletes it.

Verified: banner fires for api.sendly.now and does NOT fire for a local path or
a localhost URL; annotation only under GITHUB_ACTIONS.
@AminDhouib
AminDhouib merged commit ccbb00c into main Sep 2, 2026
1 check passed
@AminDhouib
AminDhouib deleted the fix/sync-spec-no-production-default branch September 2, 2026 01:35
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.

1 participant