fix(backend): credential issuance fails against a clean install (Draft & Publish id drift) - #118
Closed
arqueon wants to merge 1 commit into
Conversation
…t & Publish id drift)
POST /api/credentials/issue -- the flow documented in this repo's own
README -- fails on a fresh database with either:
"Error creating status list credential: Issuer not found"
"1 relation(s) of type api::revocation-list.revocation-list associated
with this entity do not exist"
Root cause, confirmed empirically with `strapi console` against a real
Postgres instance (not just by reading code): `revocation-list` has
draftAndPublish enabled. In Strapi 5, entityService.update() on an
already-published Draft & Publish entity does not update that row in
place -- it deletes it and creates a new draft/published pair (same
documentId, different numeric id):
const upd = await strapi.entityService.update(
'api::revocation-list.revocation-list', 6, { data: { nextIndex: 1 } }
)
// upd.id === 7, not 6 -- row 6 no longer exists
assignNextIndex() calls exactly this update() and discards the id it
returns, handing back only the plain index. issue() (credential.ts) goes
on using the pre-update statusList.id when creating the credential --
which, by the time the credential is actually created, may already be a
deleted row.
Fix:
- assignNextIndex() now returns { index, statusListId }, using the id
entityService.update() actually produced, and issue() uses that for
the credential's `statusList` relation instead of the stale id.
- createStatusListCredential() re-fetches the published copy by
documentId, since entityService.create() with publishedAt set can
likewise return the draft row's id.
- getOrCreateActiveListForIssuer()'s existence check now filters on
status: 'published', so it can never reuse a draft-only id.
- The real fix: revocation-list's schema now has draftAndPublish: false.
A revocation list is mutable operational state (a counter and a
bitstring that change on every issuance and revocation), not editorial
content someone reviews before publishing -- it should never have had
Draft & Publish enabled. With it off, update() always mutates the same
row, and the id-tracking changes above become defense in depth rather
than strictly necessary.
Why the existing test suite didn't catch this: the unit tests' fake
`strapi.entityService.update()` mutates the existing id in place, which
does not match Strapi 5's real behavior for a Draft & Publish content
type. Updated the two existing assertions for the new return shape, and
added a new test with a mock that faithfully reproduces the real
update()-replaces-the-row behavior, asserting assignNextIndex() returns
an id that actually still resolves.
Verified end-to-end against a real, clean install (build from this
branch, Postgres, no seed data reused): created and published an issuer
profile and an achievement via the admin content-manager API, issued a
synthetic credential (signed, Ed25519Signature2020), verified it
(verified: true), revoked it, and verified again (verified: false,
not_revoked). Did not audit whether the same entityService.update()
pattern affects other Draft & Publish content types elsewhere in the
codebase -- scoped this fix to what blocks credential issuance.
✅ Deploy Preview for certo canceled.
|
arqueon
added a commit
to arqueon/certo
that referenced
this pull request
Sep 13, 2026
…tial, evidence, endorsement (fork-only) Every content type in this repo has draftAndPublish: true -- clearly just the Strapi content-type generator's default, not a deliberate editorial workflow decision (nobody would want draft review on a signed credential or on evidence attached to one). In practice this caused real, reproducible breakage beyond what Schroedinger-Hat#118 fixes for revocation-list specifically: - entityService.findOne('api::profile.profile', id) can return the DRAFT row for a numeric id that only the PUBLISHED row actually has -- confirmed empirically: created and published a profile (draft id 4, published id 8), and findOne(..., 8) returned the row with id 4, publishedAt: null. - Connecting a relation (achievement.creator) from an entity being edited in draft context connects to the draft version of the target by default; getting it to point at the published version required an explicit `status: "published"` on the `connect` payload when using the raw content-manager API (the admin UI itself may handle this transparently, but this is unreachable from the public REST API these content types are meant to serve). Disabling draftAndPublish removes the entire class of problem: every one of these content types now only ever has one row per document, so id lookups and relation connects behave the way a straightforward create/read/update data model would. NOT proposed upstream as-is -- this is a real behavior change (removes the "Publish" step from the admin UI for these content types) that Schroedinger Hat may have reasons to want, even if it isn't exercised by this evaluation. Filed as an informational issue instead so maintainers can decide how they want to address the underlying id-instability pattern; kept as a fork-only patch for this deployment, where none of these content types need editorial review before going live. Verified end-to-end against a fresh database: created an issuer profile and an achievement with a plain, direct relation set (no more connect+status workaround needed), issued and signed a credential on the first attempt, fetched its BitstringStatusListCredential document, revoked it, and re-verified -- all without a single Draft & Publish related error.
Member
|
Please read through this: https://meatproxy.me/ Not taking time to review this, please avoid opening vibe slop PRs. Closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
POST /api/credentials/issue-- the flow documented in this repo's own README -- fails on a fresh database with either:or, further along the same request:
This isn't a config/environment problem -- it reproduces against a clean install with a correctly created and published issuer profile and achievement.
Root cause
Confirmed empirically with
strapi consoleagainst a real Postgres instance (not just by reading the code).revocation-listhasdraftAndPublish: true. In Strapi 5,entityService.update()on an already-published Draft & Publish entity does not update that row in place -- it deletes it and creates a new draft/published pair (samedocumentId, different numeric id):assignNextIndex()calls exactly thisupdate()and discards the id it returns, handing back only the plain index.issue()(credential.ts) goes on using the pre-updatestatusList.idwhen creating the credential -- which, by the time the credential is actually created, may already be a deleted row.Fix
assignNextIndex()now returns{ index, statusListId }, using the identityService.update()actually produced;issue()uses that for the credential'sstatusListrelation instead of the stale id.createStatusListCredential()re-fetches the published copy bydocumentId, sinceentityService.create()withpublishedAtset can likewise return the draft row's id.getOrCreateActiveListForIssuer()'s existence check now filters onstatus: 'published', so it can never reuse a draft-only id.revocation-list's schema now hasdraftAndPublish: false. A revocation list is mutable operational state (a counter and a bitstring that change on every issuance and revocation) -- not editorial content someone reviews before publishing. It should never have had Draft & Publish enabled. With it off,update()always mutates the same row, and the id-tracking changes above become defense-in-depth rather than strictly necessary.Why the existing test suite didn't catch this
revocation-list.test.ts's fakestrapi.entityService.update()mutates the existing id in place -- it doesn't match Strapi 5's real behavior for a Draft & Publish content type. I updated the two existing assertions for the new return shape, and added a new test with a mock that faithfully reproduces the real update()-replaces-the-row behavior, assertingassignNextIndex()returns an id that actually still resolves (and that the old one doesn't).Test plan
docker-compose upon a clean database (correctly created and published issuer profile + achievement via the admin content-manager API).Ed25519Signature2020), verified it (verified: true), revoked it, and verified again (verified: false,not_revoked).assignNextIndex's new return shape and the id-drift scenario specifically.Scope note: I did not audit whether the same
entityService.update()-on-a-published-Draft-&-Publish-entity pattern affects other content types elsewhere in the codebase (several others also havedraftAndPublish: true, e.g.credentialitself, whose ownrevokeflow updates the credential row). I scoped this fix to what concretely blocks credential issuance, which is the most severe and immediately reproducible instance of the pattern.🤖 Generated with Claude Code