Skip to content

feat: add a canonical string for nudging a site to check in - #11

Merged
Coysh merged 1 commit into
mainfrom
feat/canonical-nudge
Aug 7, 2026
Merged

Coysh merged 1 commit into
mainfrom
feat/canonical-nudge

Conversation

@Coysh

@Coysh Coysh commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Adds CanonicalNudge, the canonical string the platform signs when it asks a site to check in now. Everywhere else in this protocol the connector calls out and the platform answers; this is the first thing pointing the other way.

Why

A backup requested in Manager is a queued row and nothing more. The site collects it on its next jobs/claim — up to five minutes away with cron, and on a site whose scheduler runs off ordinary web traffic, however long it takes for somebody to visit. The screen is correct for that whole time and looks broken.

The shape

MGR1-NUDGE
{siteId}
{timestamp}
{nonce}

The nudge carries no instruction, and that is the whole design. No field for a job type, a command, a capability, a path or a destination. A site that receives one does exactly what it would have done on its own a few minutes later: make an ordinary signed claim and find out for itself what is waiting. Every decision that matters — whether a capability is still granted, which recipients an artifact may be sealed to, where an upload may go — is downstream of that claim and untouched by this. The worst a forged, replayed or misdirected nudge achieves is an early poll.

A second kind of nudge would need a second prefix, which is a change to this package and a moved fixture. The vocabulary is held to one word by the wire format rather than by a comment asking nicely.

Why a third prefix

One key signs claim responses and nudges both, so the prefixes are the only thing keeping their canonical strings from colliding — the same reason MGR1 and MGR1-RESPONSE are distinct. There is a test asserting a signed response cannot be presented as a nudge or the reverse, rather than trusting two constants to stay different by inspection.

Left out deliberately

  • The request path — a Craft action URL varies with actionTrigger, headlessMode, omitScriptNameInUrls and subfolder installs, and anything in front may rewrite it. Signing it would fail on sites behaving perfectly, in exchange for no authority: siteId already says which installation this is for.
  • A body hash — there is no body. A canonical string with nowhere to put one is a stronger guarantee than hashing an empty string.
  • A connector version — a nudge travels platform to site. nudgeHeaders() names four headers; requiredRequestHeaders() is deliberately untouched, because one list fitting both directions would fit neither.
  • A separate signing key — the existing Ed25519 key, already pinned by every connector at pairing. A nudge grants nothing a claim response does not.

Replay protection is the receiver's job and is not specified here beyond providing the timestamp and nonce to do it with.

Compatibility

Add-only. No existing schema, canonical string, signature or fixture moved — the request and response fixture blocks are byte-identical, and they are the change detector. A 1.7.1 platform and a 1.7.1 connector are unaffected by taking this; what it adds is unused until both sides are updated.

Minor bump to 1.8.0: a capability that did not exist before and that somebody can choose to use.

Verification

  • composer test — 391 passed (2815 assertions), including 6 new signing tests
  • composer phpstan — no errors
  • bin/preflight.sh — both invariants suites green
  • The nudge fixture signature is generated with the committed test keypair, not hand-written

Everywhere else in this protocol the connector calls out and the platform
answers. CanonicalNudge is the first thing pointing the other way.

It exists because of a wait nobody could shorten from the platform side. A
backup requested in Manager is a queued row and nothing more - the site
collects it on its next claim, up to five minutes away with cron and, on a
site whose scheduler runs off ordinary web traffic, however long it takes
for somebody to visit.

The nudge carries no instruction, and that is the whole design. Four lines:
prefix, site, timestamp, nonce. There is no field for a job type, a command,
a capability, a path or a destination, so a site receiving one does exactly
what it would have done on its own a few minutes later - make an ordinary
signed claim and find out for itself what is waiting. Every decision that
matters is downstream of that claim and untouched here, so the worst a
forged or replayed nudge achieves is an early poll. A second kind of nudge
would need a second prefix, which is a change to this package and a moved
fixture; the vocabulary is held to one word by the wire format rather than
by a comment asking nicely.

MGR1-NUDGE is a third prefix rather than a reuse of MGR1-RESPONSE because
one key signs both, so the prefixes are the only thing keeping their
canonical strings from colliding. There is a test asserting a signed
response cannot be presented as a nudge or the reverse, rather than trusting
two constants to stay different by inspection.

Left out deliberately: the request path, because a Craft action URL varies
with actionTrigger, headlessMode, omitScriptNameInUrls and subfolder
installs and anything in front may rewrite it - signing it would fail on
sites behaving perfectly in exchange for no authority. A body hash, because
there is no body, and a canonical string with nowhere to put one is a
stronger guarantee than hashing an empty string. A connector version, since
a nudge travels platform to site; nudgeHeaders() names four headers and
requiredRequestHeaders() is untouched, because one list fitting both
directions would fit neither.

Add-only. No existing schema, canonical string, signature or fixture moved,
so a 1.7.1 platform and a 1.7.1 connector are unaffected by taking this.
@Coysh
Coysh merged commit 91ea74e into main Aug 7, 2026
3 checks passed
@Coysh
Coysh deleted the feat/canonical-nudge branch August 17, 2026 17:04
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