Skip to content

fix: allow skill owner to move skill to an org acct while publishing existing skill#1562

Open
pushmatrix wants to merge 2 commits intoopenclaw:mainfrom
pushmatrix:fix/allow-owner-move-skill-publisher
Open

fix: allow skill owner to move skill to an org acct while publishing existing skill#1562
pushmatrix wants to merge 2 commits intoopenclaw:mainfrom
pushmatrix:fix/allow-owner-move-skill-publisher

Conversation

@pushmatrix
Copy link
Copy Markdown

@pushmatrix pushmatrix commented Apr 6, 2026

Fixes: #1431

Summary

  • When republishing a skill under a different publisher (e.g. moving from personal to org), insertVersion incorrectly threw "Slug is already taken" even when the caller owns the skill
  • The check at convex/skills.ts:5820 treated any ownerPublisherId mismatch as a conflict with no escape hatch for the actual owner
  • Now, if the caller is the ownerUserId, the skill's publisher is updated instead of throwing
  • Slug aliases are also updated to stay in sync (matching the pattern used by the transfer flow)

Test plan

  • Added test: owner can move skill from personal publisher to org publisher
  • Added test: non-owner is still blocked from claiming a skill with a different publisher
  • Added test: slug aliases are updated when moving skill to a different publisher
  • Manual: republish an existing skill under a different org via the web UI

🤖 Generated with Claude Code

When a skill already has an ownerPublisherId, the insertVersion mutation
treated any publisher mismatch as a slug conflict — even when the caller
is the skill's ownerUserId. This made it impossible to move your own
skill from a personal publisher to an org publisher (or vice versa).

Now, when ownerPublisherId differs but the caller is the ownerUserId,
we update the skill's publisher instead of throwing. Slug aliases are
also updated to stay in sync, matching the pattern used by the transfer
flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 6, 2026

@pushmatrix is attempting to deploy a commit to the 0xBuns Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 6, 2026

Greptile Summary

The production change in convex/skills.ts is correct: it allows a skill owner to reassign their skill to a different publisher, provided they already hold the "publisher" role in the destination (enforced by the existing requirePublisherRole check at line 5787). The slug-alias sync matches the existing transfer-flow pattern.

All three new tests, however, share a mock gap that causes them to fail in CI before the new code path is reached.

Confidence Score: 4/5

Production logic is correct, but all three new tests fail before exercising the new code path — the feature is effectively untested.

Two P1 findings: the positive tests never call patch (so the assertions fail) and the negative test asserts the wrong error. The new behavior is not validated by the test suite.

convex/skills.rateLimit.test.ts — all three new tests need db.get mock fixes

Prompt To Fix All With AI
This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1364-1376

Comment:
**`db.get` mock missing `"publishers:org"` — positive test never exercises new code**

With `ownerPublisherId: "publishers:org"` and `personalPublisher._id = "publishers:personal"`, the handler calls `requirePublisherRole(ctx, { publisherId: "publishers:org", ... })` at `skills.ts:5788`. That function calls `ctx.db.get("publishers:org")`, which returns `null` here, so `isPublisherActive(null)` → false → throws `ConvexError("Publisher not found")`. `patch` is never called and the `expect(patch).toHaveBeenCalledWith(...)` assertion fails.

Add the missing publisher to the `get` mock:

```suggestion
      get: vi.fn(async (id: string) => {
        if (id === "users:owner") {
          return {
            _id: "users:owner",
            handle: "pushmatrix",
            deletedAt: undefined,
            deactivatedAt: undefined,
          };
        }
        if (id === "publishers:personal") {
          return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
        }
        if (id === "publishers:org") {
          return { _id: "publishers:org", kind: "org", deletedAt: undefined, deactivatedAt: undefined };
        }
        return null;
      }),
```

The same gap exists in the third test ("updates slug aliases", line 1501–1508) — add the identical `"publishers:org"` branch there as well.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1440-1495

Comment:
**Wrong error asserted — test fails for the wrong reason**

The test expects `rejects.toThrow(/slug is already taken/i)`, but execution never reaches the slug-conflict check. Because `ownerPublisherId: "publishers:strangerOrg"``personalPublisher._id` (`"publishers:personal"`), `requirePublisherRole` is called. `ctx.db.get("publishers:strangerOrg")` returns `null` (not in the mock), so the function throws `ConvexError("Publisher not found")` — not "slug is already taken".

For this test to validate that a non-owner is blocked at the slug level, the mock needs `"publishers:strangerOrg"` to resolve as a valid publisher, and the stranger must appear as a member. Without those, the test only confirms that publishing to a non-existent publisher is blocked, which is already covered elsewhere.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: allow skill owner to move skill to ..." | Re-trigger Greptile

Comment on lines +1364 to +1376
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
handle: "pushmatrix",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
}
return null;
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.

P1 db.get mock missing "publishers:org" — positive test never exercises new code

With ownerPublisherId: "publishers:org" and personalPublisher._id = "publishers:personal", the handler calls requirePublisherRole(ctx, { publisherId: "publishers:org", ... }) at skills.ts:5788. That function calls ctx.db.get("publishers:org"), which returns null here, so isPublisherActive(null) → false → throws ConvexError("Publisher not found"). patch is never called and the expect(patch).toHaveBeenCalledWith(...) assertion fails.

Add the missing publisher to the get mock:

Suggested change
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
handle: "pushmatrix",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
}
return null;
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
handle: "pushmatrix",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
}
if (id === "publishers:org") {
return { _id: "publishers:org", kind: "org", deletedAt: undefined, deactivatedAt: undefined };
}
return null;
}),

The same gap exists in the third test ("updates slug aliases", line 1501–1508) — add the identical "publishers:org" branch there as well.

Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1364-1376

Comment:
**`db.get` mock missing `"publishers:org"` — positive test never exercises new code**

With `ownerPublisherId: "publishers:org"` and `personalPublisher._id = "publishers:personal"`, the handler calls `requirePublisherRole(ctx, { publisherId: "publishers:org", ... })` at `skills.ts:5788`. That function calls `ctx.db.get("publishers:org")`, which returns `null` here, so `isPublisherActive(null)` → false → throws `ConvexError("Publisher not found")`. `patch` is never called and the `expect(patch).toHaveBeenCalledWith(...)` assertion fails.

Add the missing publisher to the `get` mock:

```suggestion
      get: vi.fn(async (id: string) => {
        if (id === "users:owner") {
          return {
            _id: "users:owner",
            handle: "pushmatrix",
            deletedAt: undefined,
            deactivatedAt: undefined,
          };
        }
        if (id === "publishers:personal") {
          return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
        }
        if (id === "publishers:org") {
          return { _id: "publishers:org", kind: "org", deletedAt: undefined, deactivatedAt: undefined };
        }
        return null;
      }),
```

The same gap exists in the third test ("updates slug aliases", line 1501–1508) — add the identical `"publishers:org"` branch there as well.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1440 to +1495
);
});

it("blocks non-owner from claiming a skill with a different publisher", async () => {
const db = {
get: vi.fn(async (id: string) => {
if (id === "users:stranger") {
return { _id: "users:stranger", handle: "stranger", deletedAt: undefined, deactivatedAt: undefined };
}
if (id === "users:owner") {
return { _id: "users:owner", handle: "alice", deletedAt: undefined, deactivatedAt: undefined };
}
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:stranger" };
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: (name: string) => {
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
return {
unique: async () => ({
_id: "skills:1",
slug: "shop",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:other",
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
}),
};
},
};
}
if (table === "publishers") {
return {
withIndex: () => ({ unique: async () => ({ _id: "publishers:personal", kind: "user", linkedUserId: "users:stranger" }) }),
};
}
throw new Error(`unexpected table ${table}`);
}),
normalizeId: vi.fn(),
};

await expect(
insertVersionHandler(
{ db } as never,
createPublishArgs({
userId: "users:stranger",
slug: "shop",
ownerPublisherId: "publishers:strangerOrg",
}) as never,
),
).rejects.toThrow(/slug is already taken/i);
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.

P1 Wrong error asserted — test fails for the wrong reason

The test expects rejects.toThrow(/slug is already taken/i), but execution never reaches the slug-conflict check. Because ownerPublisherId: "publishers:strangerOrg"personalPublisher._id ("publishers:personal"), requirePublisherRole is called. ctx.db.get("publishers:strangerOrg") returns null (not in the mock), so the function throws ConvexError("Publisher not found") — not "slug is already taken".

For this test to validate that a non-owner is blocked at the slug level, the mock needs "publishers:strangerOrg" to resolve as a valid publisher, and the stranger must appear as a member. Without those, the test only confirms that publishing to a non-existent publisher is blocked, which is already covered elsewhere.

Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1440-1495

Comment:
**Wrong error asserted — test fails for the wrong reason**

The test expects `rejects.toThrow(/slug is already taken/i)`, but execution never reaches the slug-conflict check. Because `ownerPublisherId: "publishers:strangerOrg"``personalPublisher._id` (`"publishers:personal"`), `requirePublisherRole` is called. `ctx.db.get("publishers:strangerOrg")` returns `null` (not in the mock), so the function throws `ConvexError("Publisher not found")` — not "slug is already taken".

For this test to validate that a non-owner is blocked at the slug level, the mock needs `"publishers:strangerOrg"` to resolve as a valid publisher, and the stranger must appear as a member. Without those, the test only confirms that publishing to a non-existent publisher is blocked, which is already covered elsewhere.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 895f811bcc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1373 to +1376
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
}
return null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Mock target publisher before asserting move-publisher behavior

These new tests pass ownerPublisherId values like publishers:org, but the db.get mock only returns publishers:personal and otherwise returns null. In insertVersion, that causes requirePublisherRole to fail with Publisher not found before the owner/non-owner slug logic runs, so the tests do not actually exercise the behavior they claim to validate and are likely to fail for the wrong reason.

Useful? React with 👍 / 👎.

The db.get mock was missing the org publisher entry, so
requirePublisherRole would throw "Publisher not found" before
reaching the code under test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pushmatrix pushmatrix changed the title fix: allow skill owner to move skill to a different publisher fix: allow skill owner to move skill to an org acct while publishing Apr 6, 2026
@pushmatrix pushmatrix changed the title fix: allow skill owner to move skill to an org acct while publishing fix: allow skill owner to move skill to an org acct while publishing existing skill Apr 6, 2026
@lbs-bmap
Copy link
Copy Markdown

lbs-bmap commented Apr 7, 2026

This is useful for me! Thank you!
Wish it will be published online soon!

@momothemage momothemage self-assigned this Apr 9, 2026
@momothemage
Copy link
Copy Markdown

Please note that CI / build (pull_request)Failing after 1m. All test cases must be successful before merge. @pushmatrix

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.

Cannot change an existing skill's publisher/owner to an organization

3 participants