Skip to content

feat(resource-servers): support UCP type and release it before reassigning - #1530

Merged
harshithRai merged 8 commits into
masterfrom
DXCDT-2518
Nov 5, 2026
Merged

harshithRai merged 8 commits into
masterfrom
DXCDT-2518

Conversation

@harshithRai

@harshithRai harshithRai commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

🔧 Changes

Adds support for the resource server type property, the Early Access flag that declares an API as a Universal Commerce Protocol (UCP) server. The only accepted value is universal_commerce_protocol_server, and null clears it. The Management API gates setting the type behind a tenant feature flag and allows only one UCP-typed resource server per tenant, so the CLI handles both rules explicitly.

  • Bumps auth0 to ^7.7.0, which ships type and ResourceServerTypeEnum (auth0/node-auth0#1436). The handler schema accepts type as an SDK enum value or null. Both the YAML and directory contexts already pass fields through, so no parser changes were needed.
  • validate() rejects a config with more than one UCP-typed resource server before any API call, instead of failing later with a 409. Excluded and system resource servers are ignored, since their type is never sent. It also warns when type: null is combined with dev.ucp scopes, which the API reserves for the UCP-typed server.
  • processChanges() updates the current holder first when a config releases the type (type: null). Without this, moving the type from one resource server to another in a single deploy could fail with a 409, because creates run before updates and updates run in parallel. If the update that follows the release fails, the error says the type was already released and to re-run. Imports that don't release a held type take the same single-call path as before.
  • Logs a warning when another resource server is given the type but the current holder is not released or deleted: the holder omits type, is excluded by name, or is removed from the config while deletes are off. The warning names the fix. A dryRunChanges override runs the same check for --dry-run, where the spinner hides handler logs, so it shows there with AUTH0_DEBUG=true.
  • Dry run no longer reports an UPDATE for type: null on a server that doesn't hold the type, since the API omits type for those servers.
  • Renames the directory context test to resourceServers.test.js so the test/**/*.test* glob that CI uses runs it, and fixes the client_id test mock there (getAll to list), which only failed once the file was actually run.
  • Dry run now leaves excluded resource servers out of the preview, as the import does, and the holder warning names the exclusion when the holder is excluded.

Omitting type leaves the existing value unchanged (the API treats it as a PATCH), so clearing it requires an explicit type: null. A holder giving up the type must also empty its dev.ucp scopes (scopes: []), because omitting scopes leaves them in place. This is documented in docs/resource-specific-documentation.md, with a commented example in examples/yaml/tenant.yaml. No existing config is affected.

Shape

YAML, before:

resourceServers:
  - name: Commerce API
    identifier: https://commerce.example.com

YAML, after:

resourceServers:
  - name: Commerce API
    identifier: https://commerce.example.com
    type: universal_commerce_protocol_server # or null to clear

Directory (JSON), before:

{
  "name": "Commerce API",
  "identifier": "https://commerce.example.com"
}

Directory (JSON), after:

{
  "name": "Commerce API",
  "identifier": "https://commerce.example.com",
  "type": "universal_commerce_protocol_server"
}

🔬 Testing

Unit tests

Added in test/tools/auth0/handlers/resourceServers.tests.js:

  • Rejects more than one UCP-typed resource server, ignoring excluded and system ones, and allows one alongside others including type: null
  • Warns on type: null combined with dev.ucp scopes, and stays quiet for the UCP-typed server and for other scopes
  • Schema accepts every SDK enum value, null, and an omitted type, and rejects any other value
  • Creates with the type, sets it on an existing server, clears it with null, leaves it unset when omitted, and leaves a holder that keeps its type alone
  • Releases the holder before another existing server, a new server, or a replacement takes the type, regardless of config order, with no ids in the config, and when deletes are off
  • Releases only the server that actually holds the type, and applies a release with no taker
  • Says the type was already released when the update after the release fails
  • Warns when the holder omits type, is excluded, or is removed with deletes off, and not when it is deleted (including by an extension), released, or keeps the type
  • Dry run: shows the same warning, does not fetch resource servers when the config has none, ignores type: null on a server that doesn't hold the type, and reports it on the holder

Added in test/context/yaml/resourceServers.test.js and test/context/directory/resourceServers.test.js:

  • Parses type, including null (and an empty type: in YAML, which becomes null)
  • Dumps type only when it is set

Manual verification

Run against a live tenant with the UCP feature flag enabled, scoped to resource servers only, and verified by reading the tenant back through the Management API in both YAML and directory formats:

  1. Dry-run a config that sets type and confirm the preview lists the expected changes and the tenant is unchanged. Confirm type: null on a server without the type shows no change.
  2. Import it and confirm the API shows the type on the holder only. Re-import and confirm nothing changes.
  3. Export and confirm the holder exports type and others omit it. Import that export and confirm the state is preserved.
  4. Give the type to a second server while the holder omits type, and confirm the warning appears (with AUTH0_DEBUG=true in dry run) and the API returns 409.
  5. Move the type in a single import, with the new holder listed first, and confirm the old holder is updated first. Repeat by creating a new server, and by deleting the holder and creating a replacement.
  6. Make the update after a release fail, for example with an invalid token_lifetime, and confirm the error says the type was already released and that a re-run recovers.
  7. Set type: null and confirm it clears. Omit type on the holder and confirm it stays.
  8. Confirm a config with two UCP-typed servers fails validation, and an unknown type value fails schema validation.
  9. Remove the test resource servers and confirm an export matches the original tenant.

📝 Checklist

  • All new/changed/fixed functionality is covered by tests (or N/A)
  • I have added documentation for all new/changed functionality (or N/A)

…gning

Co-Authored-By: Claude <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Nov 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.12%. Comparing base (53f3683) to head (941433b).

Files with missing lines Patch % Lines
src/tools/auth0/handlers/resourceServers.ts 94.73% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1530      +/-   ##
==========================================
+ Coverage   81.83%   82.12%   +0.28%     
==========================================
  Files         190      190              
  Lines        8923     8977      +54     
  Branches     1977     1990      +13     
==========================================
+ Hits         7302     7372      +70     
+ Misses        839      818      -21     
- Partials      782      787       +5     

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

harshithRai and others added 2 commits November 2, 2026 18:11
@harshithRai
harshithRai marked this pull request as ready for review November 2, 2026 14:50
@harshithRai
harshithRai requested a review from a team as a code owner November 2, 2026 14:50
};

// Only one resource server can hold the UCP type, so the holder giving it up is updated first
const holder = this.existing?.find((e) => e.type === RESOURCE_SERVER_TYPE_UCP);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this check only runs in processChanges(), does --dry-run ever show the warning? I tried a holder with no type plus another server setting it. The preview looked clean, then the real import hit a 409. Could we share this with a dryRunChanges override?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added a dryRunChanges override that runs the same check. The dry-run spinner hides handler logs though, so it only shows with AUTH0_DEBUG=true. Documented that.

}
}

if (releasing.length) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the release goes through but the new holder's update then fails, do we end up with no UCP server at all? That's what I saw with a mocked failure. Maybe worth saying in the error that the type was already released?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the error now says the type was already released and to re-run. Checked on a tenant: after the failure no server holds it, and the re-run fixes it.

if (taker && !(deletes && changes.del.some((r) => r.id === holder.id))) {
const { name } = holder;
log.warn(
`'${name}' already has type '${RESOURCE_SERVER_TYPE_UCP}', so setting it on '${taker.name}' is likely to fail with a 409. Set 'type: null' on '${name}' in the same config to move it.`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: if the holder is a skipped delete (deletes off), would pointing to AUTH0_ALLOW_DELETE=true be a better hint than type: null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the hint now points to AUTH0_ALLOW_DELETE when the holder is a skipped delete.


// The API allows only one UCP-typed resource server per tenant; fail early instead of on a 409
const excluded = assets.exclude?.resourceServers || [];
const ucpResourceServers = resourceServers.filter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type on a system API seems to get silently dropped in updateResourceServer, but it still counts here. Should we skip or reject is_system servers in this check? Not sure that can even happen, though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped is_system servers in the count, same as excluded ones. They drop type on update, so I left that as is rather than rejecting it.


// The API allows only one UCP-typed resource server per tenant; fail early instead of on a 409
const excluded = assets.exclude?.resourceServers || [];
const ucpResourceServers = resourceServers.filter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're failing early here anyway, could we also catch type: null combined with dev.ucp* scopes, before the API 400s? Fine to skip if you'd rather leave it to the API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as a warning rather than an error. The API only enforces it behind a flag, so a hard error could block a config it would accept.

@@ -36,6 +36,32 @@ describe('#directory context resourceServers', () => {
expect(context.assets.resourceServers).to.deep.equal(resourceServersTarget);
});

it('should process the resource server type, including null', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this file isn't picked up by the npm test glob (no .test in the name), so these new tests don't run in CI. Rename it? Heads up: the existing client_id test at line 164 fails once it actually runs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, renamed to resourceServers.test.js so CI picks it up, and fixed the client_id test mock (getAll to list).

expect(calls).to.deep.equal([`update:rs1:${UCP}`, 'update:rs2:undefined']);
});

it('should clear the holder before another existing server takes the type', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the desired configs here include id, but real ones won't. Could we add one case with just name/identifier? It works, I checked, but a test would keep it that way

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a case with just name and identifier.

Comment thread docs/resource-specific-documentation.md Outdated

- Resource servers that don't hold the type have no `type` property in API responses, so exports are unchanged for them.
- `type: null` is accepted whether or not the tenant has the feature flag. Setting `universal_commerce_protocol_server` without the flag fails with `403`.
- A config with `type: null` on a server that doesn't hold the type shows as an `UPDATE` in every dry run. The update itself changes nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could dry run treat type: null the same as no type when the server doesn't hold it? Then we wouldn't need this note. Not a blocker!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. dryRunChanges now drops type: null for servers that don't hold the type, so the note is gone.

Comment thread examples/yaml/tenant.yaml
- value: "read:account"
description: "read account"
# client_id: "if linked to a resource server client (readonly)"
# type: "universal_commerce_protocol_server" # EA, one per tenant, null clears it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding a directory example too, or skipping it on purpose since JSON can't take the comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping on purpose. JSON can't take the comment, and a live UCP file would fail for anyone on a tenant without the flag. The docs have the directory snippet.

harshithRai and others added 2 commits November 4, 2026 13:02
Co-Authored-By: Claude <noreply@anthropic.com>
if (!taker) return;

const holderInDel = changes.del.some((r) => r.id === holder.id);
// Mirrors the delete gating in DefaultHandler.processChanges

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One catch: dry run doesn't apply exclusions, so an excluded holder still shows up in del. With deletes on, dry run stays quiet but the import 409s; with deletes off, the hint says AUTH0_ALLOW_DELETE, which won't help. Could we check excluded.includes(holder.name) first and give an "it's excluded" hint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Dry run now filters excluded servers the same way the import does (filterExcluded), so an excluded holder no longer shows as a DELETE and the warning fires with deletes on or off. The hint now says it's excluded through AUTH0_EXCLUDED_RESOURCE_SERVERS instead of pointing at AUTH0_ALLOW_DELETE. Checked on a tenant. Excluded servers also drop out of the dry-run table, matching the import.

@harshithRai
harshithRai merged commit 591c5f3 into master Nov 5, 2026
13 checks passed
@harshithRai
harshithRai deleted the DXCDT-2518 branch November 5, 2026 06:35
@harshithRai harshithRai mentioned this pull request Nov 5, 2026
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.

3 participants