mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb702bb57 | ||
|
|
6c8ace3755 | ||
|
|
162f4d24f8 | ||
|
|
3e7deacce8 | ||
|
|
a90563d9d2 | ||
|
|
74455bb860 | ||
|
|
704d9457aa | ||
|
|
bf7a1d32c9 | ||
|
|
d467ae3d75 |
@@ -5,6 +5,7 @@
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
- Skill ownership transfers now support org targets: `transfer request` and `transfer accept` accept a `--publisher` flag to send or receive a skill on behalf of an org. Org admins can see incoming org-targeted transfers via `GET /api/v1/transfers/incoming`. (#1603 thanks @TommYDeeee)
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
|
||||
@@ -969,11 +969,26 @@ async function handleTransferRequest(
|
||||
if (!toUserHandleRaw) return text("toUserHandle required", 400, headers);
|
||||
const message = typeof parsed.payload.message === "string" ? parsed.payload.message : undefined;
|
||||
|
||||
// Resolve optional publisher handle to a publisher ID
|
||||
const toPublisherHandleRaw =
|
||||
typeof parsed.payload.toPublisherHandle === "string"
|
||||
? parsed.payload.toPublisherHandle.trim()
|
||||
: "";
|
||||
let toPublisherId: Id<"publishers"> | undefined;
|
||||
if (toPublisherHandleRaw) {
|
||||
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
|
||||
handle: toPublisherHandleRaw,
|
||||
});
|
||||
if (!publisher) return text("Publisher not found", 404, headers);
|
||||
toPublisherId = publisher._id;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
|
||||
actorUserId: transferContext.userId,
|
||||
skillId: transferContext.skill._id,
|
||||
toUserHandle: toUserHandleRaw,
|
||||
toPublisherId,
|
||||
message,
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
@@ -992,30 +1007,68 @@ async function handleTransferDecision(
|
||||
const transferContext = await resolveTransferContext(ctx, request, slug, headers);
|
||||
if (!transferContext.ok) return transferContext.response;
|
||||
|
||||
const pendingTransfer =
|
||||
decision === "cancel"
|
||||
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
fromUserId: transferContext.userId,
|
||||
})
|
||||
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
toUserId: transferContext.userId,
|
||||
});
|
||||
let pendingTransfer;
|
||||
if (decision === "cancel") {
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal,
|
||||
{ skillId: transferContext.skill._id, fromUserId: transferContext.userId },
|
||||
);
|
||||
if (!pendingTransfer) {
|
||||
// Fallback: allow org admins to cancel transfers initiated by other admins
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillInternal,
|
||||
{ skillId: transferContext.skill._id },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Try user-specific lookup first, then fall back to any pending transfer
|
||||
// for the skill (allows org admins other than toUserId to accept/reject)
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillAndUserInternal,
|
||||
{ skillId: transferContext.skill._id, toUserId: transferContext.userId },
|
||||
);
|
||||
if (!pendingTransfer) {
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillInternal,
|
||||
{ skillId: transferContext.skill._id },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!pendingTransfer) return text("No pending transfer found", 404, headers);
|
||||
|
||||
const mutation =
|
||||
decision === "accept"
|
||||
? internal.skillTransfers.acceptTransferInternal
|
||||
: decision === "reject"
|
||||
? internal.skillTransfers.rejectTransferInternal
|
||||
: internal.skillTransfers.cancelTransferInternal;
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(mutation, {
|
||||
// For accept, resolve optional publisher handle to forward publisherId
|
||||
let publisherId: Id<"publishers"> | undefined;
|
||||
if (decision === "accept") {
|
||||
const parsed = await parseJsonPayload(request, headers);
|
||||
if (parsed.ok) {
|
||||
const publisherHandleRaw =
|
||||
typeof parsed.payload.publisherHandle === "string"
|
||||
? parsed.payload.publisherHandle.trim()
|
||||
: "";
|
||||
if (publisherHandleRaw) {
|
||||
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
|
||||
handle: publisherHandleRaw,
|
||||
});
|
||||
if (!publisher) return text("Publisher not found", 404, headers);
|
||||
publisherId = publisher._id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
actorUserId: transferContext.userId,
|
||||
transferId: pendingTransfer._id,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await (decision === "accept"
|
||||
? ctx.runMutation(internal.skillTransfers.acceptTransferInternal, {
|
||||
...baseArgs,
|
||||
publisherId,
|
||||
})
|
||||
: decision === "reject"
|
||||
? ctx.runMutation(internal.skillTransfers.rejectTransferInternal, baseArgs)
|
||||
: ctx.runMutation(internal.skillTransfers.cancelTransferInternal, baseArgs));
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
return transferErrorToResponse(error, headers);
|
||||
|
||||
@@ -16,9 +16,14 @@ export async function transfersGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const transfers =
|
||||
const skillTransfers =
|
||||
direction === "incoming"
|
||||
? await ctx.runQuery(internal.skillTransfers.listIncomingInternal, { userId: auth.userId })
|
||||
: await ctx.runQuery(internal.skillTransfers.listOutgoingInternal, { userId: auth.userId });
|
||||
|
||||
const transfers = skillTransfers.sort(
|
||||
(a, b) => (b.requestedAt ?? 0) - (a.requestedAt ?? 0),
|
||||
);
|
||||
|
||||
return json({ transfers }, 200, rate.headers);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TRANSFER_EXPIRY_MS,
|
||||
isTransferExpired,
|
||||
normalizeTransferHandle,
|
||||
validateTransferOwnership,
|
||||
validateTransferAcceptPermission,
|
||||
} from "./transfers";
|
||||
|
||||
describe("transfers", () => {
|
||||
it("TRANSFER_EXPIRY_MS is 7 days", () => {
|
||||
expect(TRANSFER_EXPIRY_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
describe("isTransferExpired", () => {
|
||||
it("returns true when expiresAt is in the past", () => {
|
||||
expect(isTransferExpired({ expiresAt: 1000 }, 2000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt is in the future", () => {
|
||||
expect(isTransferExpired({ expiresAt: 3000 }, 2000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt equals now", () => {
|
||||
expect(isTransferExpired({ expiresAt: 2000 }, 2000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTransferHandle", () => {
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizeTransferHandle(" alice ")).toBe("alice");
|
||||
});
|
||||
|
||||
it("strips leading @ and lowercases", () => {
|
||||
expect(normalizeTransferHandle("@Alice")).toBe("alice");
|
||||
});
|
||||
|
||||
it("strips multiple leading @ signs", () => {
|
||||
expect(normalizeTransferHandle("@@Bob")).toBe("bob");
|
||||
});
|
||||
|
||||
it("lowercases without @", () => {
|
||||
expect(normalizeTransferHandle("Charlie")).toBe("charlie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferOwnership", () => {
|
||||
it("passes for direct owner (personal, no publisher)", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:1" as never,
|
||||
ownerPublisherId: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes for org admin", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes for org owner role", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "owner",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-admin org member (publisher role)", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "publisher",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("rejects non-owner non-member", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("rejects personal item when actor is not the owner", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:2" as never,
|
||||
ownerUserId: "users:1" as never,
|
||||
ownerPublisherId: undefined,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferAcceptPermission", () => {
|
||||
it("passes for personal target when actor is the target user", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:1" as never,
|
||||
toPublisherId: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects personal target when actor is not the target user", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:2" as never,
|
||||
toUserId: "users:1" as never,
|
||||
toPublisherId: undefined,
|
||||
}),
|
||||
).rejects.toThrow("No pending transfer found");
|
||||
});
|
||||
|
||||
it("passes for org target when actor is admin", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publishers:org1",
|
||||
kind: "org",
|
||||
handle: "myorg",
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:99" as never,
|
||||
toPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects org target when actor is not admin/owner", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publishers:org1",
|
||||
kind: "org",
|
||||
handle: "myorg",
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "publisher",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:99" as never,
|
||||
toPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("No pending transfer found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { getPublisherMembership, isPublisherActive, isPublisherRoleAllowed } from "./publishers";
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
/** 7 days in milliseconds */
|
||||
export const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Returns true if the transfer has expired (expiresAt is strictly less than now). */
|
||||
export function isTransferExpired(transfer: { expiresAt: number }, now: number): boolean {
|
||||
return transfer.expiresAt < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims whitespace, strips leading `@` characters, and lowercases:
|
||||
* `"@Alice"` -> `"alice"`, `"@@Bob"` -> `"bob"`
|
||||
*/
|
||||
export function normalizeTransferHandle(value: string): string {
|
||||
return value.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `actorUserId` has permission to initiate a transfer.
|
||||
*
|
||||
* - If the item is personally owned (`ownerPublisherId` is null/undefined):
|
||||
* actor must be the `ownerUserId`.
|
||||
* - If the item is org-owned: actor must be an admin or owner in that org's
|
||||
* publisherMembers.
|
||||
*
|
||||
* @throws {Error} "Forbidden" on failure
|
||||
*/
|
||||
export async function validateTransferOwnership(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!params.ownerPublisherId) {
|
||||
// Personally owned — actor must be the owner
|
||||
if (params.actorUserId !== params.ownerUserId) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org-owned — actor must be admin or owner in the org
|
||||
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actorUserId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `actorUserId` can accept a transfer.
|
||||
*
|
||||
* - If `toPublisherId` is null (personal target): actor must be `toUserId`.
|
||||
* - If `toPublisherId` is set (org target): actor must be admin/owner of that org.
|
||||
*
|
||||
* @throws {Error} "No pending transfer found" on failure
|
||||
*/
|
||||
export async function validateTransferAcceptPermission(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
toUserId?: Id<"users"> | null;
|
||||
toPublisherId?: Id<"publishers"> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!params.toPublisherId) {
|
||||
// Personal target — actor must be the target user
|
||||
if (params.actorUserId !== params.toUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org target — publisher must be active and actor must be admin or owner
|
||||
const db = (ctx as { db: { get: (id: Id<"publishers">) => Promise<Doc<"publishers"> | null> } })
|
||||
.db;
|
||||
const publisher = await db.get(params.toPublisherId);
|
||||
if (!isPublisherActive(publisher)) {
|
||||
throw new Error("Publisher not found");
|
||||
}
|
||||
const membership = await getPublisherMembership(ctx, params.toPublisherId, params.actorUserId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1286,7 +1286,9 @@ const userSkillRootInstalls = defineTable({
|
||||
const skillOwnershipTransfers = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
fromUserId: v.id("users"),
|
||||
toUserId: v.id("users"),
|
||||
toUserId: v.optional(v.id("users")),
|
||||
fromPublisherId: v.optional(v.id("publishers")),
|
||||
toPublisherId: v.optional(v.id("publishers")),
|
||||
status: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
@@ -1303,6 +1305,7 @@ const skillOwnershipTransfers = defineTable({
|
||||
.index("by_from_user", ["fromUserId"])
|
||||
.index("by_to_user", ["toUserId"])
|
||||
.index("by_to_user_status", ["toUserId", "status"])
|
||||
.index("by_to_publisher_status", ["toPublisherId", "status"])
|
||||
.index("by_from_user_status", ["fromUserId", "status"])
|
||||
.index("by_skill_status", ["skillId", "status"]);
|
||||
|
||||
|
||||
@@ -389,4 +389,79 @@ describe("skillTransfers", () => {
|
||||
expect.objectContaining({ ownerUserId: "users:2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal allows org admin to request transfer", async () => {
|
||||
// Org admin (users:2) can request transfer of a skill owned by org (publishers:org1)
|
||||
// even though ownerUserId is users:1
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:2") return { _id: "users:2", handle: "orgadmin" };
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:org1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:org1") {
|
||||
return { _id: "publishers:org1", kind: "org", handle: "myorg" };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "users:3",
|
||||
handle: "recipient",
|
||||
displayName: "Recipient",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return { withIndex: () => ({ collect: async () => [] }) };
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:2",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return { withIndex: () => ({ unique: async () => null }) };
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:2",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@recipient",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.transferId).toBe("skillOwnershipTransfers:new");
|
||||
});
|
||||
});
|
||||
|
||||
+174
-37
@@ -5,18 +5,16 @@ import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
import {
|
||||
TRANSFER_EXPIRY_MS,
|
||||
isTransferExpired,
|
||||
normalizeTransferHandle,
|
||||
validateTransferOwnership,
|
||||
validateTransferAcceptPermission,
|
||||
} from "./lib/transfers";
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
|
||||
function normalizeHandle(value: string) {
|
||||
return value.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isExpired(transfer: TransferDoc, now: number) {
|
||||
return transfer.expiresAt < now;
|
||||
}
|
||||
|
||||
async function requireActiveUserById(ctx: unknown, userId: Id<"users">) {
|
||||
const db = (ctx as { db: { get: (id: Id<"users">) => Promise<Doc<"users"> | null> } }).db;
|
||||
const user = await db.get(userId);
|
||||
@@ -53,7 +51,7 @@ async function getActivePendingTransferForSkill(ctx: unknown, skillId: Id<"skill
|
||||
|
||||
let active: TransferDoc | null = null;
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) {
|
||||
if (isTransferExpired(transfer, now)) {
|
||||
await db.patch(transfer._id, { status: "expired", respondedAt: now });
|
||||
continue;
|
||||
}
|
||||
@@ -83,14 +81,26 @@ async function validatePendingTransferForActor(
|
||||
const transfer = await db.get(params.transferId);
|
||||
if (!transfer) throw new Error("Transfer not found");
|
||||
|
||||
if (params.role === "recipient" && transfer.toUserId !== params.actorUserId) {
|
||||
if (
|
||||
params.role === "recipient" &&
|
||||
transfer.toUserId &&
|
||||
transfer.toUserId !== params.actorUserId &&
|
||||
!transfer.toPublisherId
|
||||
) {
|
||||
// For org-targeted transfers (toPublisherId is set), skip this check —
|
||||
// validateTransferAcceptPermission handles org membership validation separately
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
if (params.role === "sender" && transfer.fromUserId !== params.actorUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
if (params.role === "sender") {
|
||||
if (!transfer.fromPublisherId && transfer.fromUserId !== params.actorUserId) {
|
||||
// Personal transfer: actor must be the original sender
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
// Org-owned transfer: actor's org membership is verified by the caller
|
||||
// (e.g. cancelTransferInternal calls validateTransferOwnership after this)
|
||||
}
|
||||
if (transfer.status !== "pending") throw new Error("No pending transfer found");
|
||||
if (isExpired(transfer, params.now)) {
|
||||
if (isTransferExpired(transfer, params.now)) {
|
||||
await db.patch(transfer._id, { status: "expired", respondedAt: params.now });
|
||||
throw new Error("Transfer has expired");
|
||||
}
|
||||
@@ -102,6 +112,7 @@ export const requestTransferInternal = internalMutation({
|
||||
actorUserId: v.id("users"),
|
||||
skillId: v.id("skills"),
|
||||
toUserHandle: v.string(),
|
||||
toPublisherId: v.optional(v.id("publishers")),
|
||||
message: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -110,14 +121,28 @@ export const requestTransferInternal = internalMutation({
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
if (skill.ownerUserId !== args.actorUserId) throw new Error("Forbidden");
|
||||
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
await validateTransferOwnership(ctx, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
actorUserId: args.actorUserId,
|
||||
});
|
||||
|
||||
const toHandle = normalizeTransferHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
if (toUser._id === args.actorUserId && !args.toPublisherId) {
|
||||
throw new Error("Cannot transfer to yourself");
|
||||
}
|
||||
|
||||
if (args.toPublisherId) {
|
||||
const toPublisher = await ctx.db.get(args.toPublisherId);
|
||||
if (!toPublisher || toPublisher.deletedAt || toPublisher.deactivatedAt) {
|
||||
throw new Error("Target publisher not found");
|
||||
}
|
||||
}
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
if (activePending) throw new Error("A transfer is already pending for this skill");
|
||||
@@ -128,6 +153,8 @@ export const requestTransferInternal = internalMutation({
|
||||
skillId: skill._id,
|
||||
fromUserId: args.actorUserId,
|
||||
toUserId: toUser._id,
|
||||
fromPublisherId: skill.ownerPublisherId,
|
||||
toPublisherId: args.toPublisherId,
|
||||
status: "pending",
|
||||
message: message || undefined,
|
||||
requestedAt: now,
|
||||
@@ -155,6 +182,7 @@ export const acceptTransferInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
transferId: v.id("skillOwnershipTransfers"),
|
||||
publisherId: v.optional(v.id("publishers")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
@@ -167,19 +195,45 @@ export const acceptTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toUserId: transfer.toUserId ?? undefined,
|
||||
toPublisherId: transfer.toPublisherId,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
if (skill.ownerUserId !== transfer.fromUserId) {
|
||||
const ownerChanged = transfer.fromPublisherId
|
||||
? skill.ownerPublisherId !== transfer.fromPublisherId
|
||||
: skill.ownerUserId !== transfer.fromUserId;
|
||||
if (ownerChanged) {
|
||||
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
|
||||
throw new Error("Transfer is no longer valid");
|
||||
}
|
||||
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
// Determine target publisher: sender's choice > recipient override > personal
|
||||
// When the sender specified a target publisher, honor it unconditionally
|
||||
let targetPublisherId: Id<"publishers">;
|
||||
if (transfer.toPublisherId) {
|
||||
targetPublisherId = transfer.toPublisherId;
|
||||
} else if (args.publisherId) {
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toPublisherId: args.publisherId,
|
||||
});
|
||||
targetPublisherId = args.publisherId;
|
||||
} else {
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
targetPublisherId = newPublisher._id;
|
||||
}
|
||||
|
||||
// For org-targeted transfers, ownerUserId is set to whichever admin accepts,
|
||||
// not necessarily the original toUserId. The toUserHandle on the request just
|
||||
// routes the transfer — actual ownership reflects who acted on it.
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
ownerPublisherId: targetPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -190,12 +244,16 @@ export const acceptTransferInternal = internalMutation({
|
||||
for (const alias of aliases) {
|
||||
await ctx.db.patch(alias._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
ownerPublisherId: targetPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
|
||||
await ctx.db.patch(transfer._id, {
|
||||
status: "accepted",
|
||||
respondedAt: now,
|
||||
toUserId: args.actorUserId,
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -229,6 +287,12 @@ export const rejectTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toUserId: transfer.toUserId ?? undefined,
|
||||
toPublisherId: transfer.toPublisherId,
|
||||
});
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "rejected", respondedAt: now });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -259,6 +323,15 @@ export const cancelTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
// For org-owned transfers, always verify actor still has admin/owner role
|
||||
if (transfer.fromPublisherId) {
|
||||
await validateTransferOwnership(ctx, {
|
||||
ownerUserId: transfer.fromUserId,
|
||||
ownerPublisherId: transfer.fromPublisherId,
|
||||
actorUserId: args.actorUserId,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -279,28 +352,63 @@ export const listIncomingInternal = internalQuery({
|
||||
const now = Date.now();
|
||||
await requireActiveUserById(ctx, args.userId);
|
||||
|
||||
const transfers = await ctx.db
|
||||
// Query transfers directed at this user personally
|
||||
const userTransfers = await ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_to_user_status", (q) => q.eq("toUserId", args.userId).eq("status", "pending"))
|
||||
.collect();
|
||||
|
||||
// Query transfers directed at orgs where this user is an admin or owner
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.collect();
|
||||
const adminPublisherIds = memberships
|
||||
.filter((m) => m.role === "owner" || m.role === "admin")
|
||||
.map((m) => m.publisherId);
|
||||
|
||||
const orgTransferArrays = await Promise.all(
|
||||
adminPublisherIds.map((publisherId) =>
|
||||
ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_to_publisher_status", (q) =>
|
||||
q.eq("toPublisherId", publisherId).eq("status", "pending"),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
const orgTransfers = orgTransferArrays.flat();
|
||||
|
||||
// Merge and deduplicate by transfer ID
|
||||
const seen = new Set<string>();
|
||||
const allTransfers: TransferDoc[] = [];
|
||||
for (const t of [...userTransfers, ...orgTransfers]) {
|
||||
if (!seen.has(t._id)) {
|
||||
seen.add(t._id);
|
||||
allTransfers.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
type: "skill";
|
||||
_id: Id<"skillOwnershipTransfers">;
|
||||
skill: { _id: Id<"skills">; slug: string; displayName: string };
|
||||
fromUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toPublisherId?: Id<"publishers">;
|
||||
message: string | undefined;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) continue;
|
||||
for (const transfer of allTransfers) {
|
||||
if (isTransferExpired(transfer, now)) continue;
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) continue;
|
||||
const fromUser = await ctx.db.get(transfer.fromUserId);
|
||||
if (!fromUser || fromUser.deletedAt || fromUser.deactivatedAt) continue;
|
||||
|
||||
results.push({
|
||||
type: "skill" as const,
|
||||
_id: transfer._id,
|
||||
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
|
||||
fromUser: {
|
||||
@@ -308,6 +416,7 @@ export const listIncomingInternal = internalQuery({
|
||||
handle: fromUser.handle ?? null,
|
||||
displayName: fromUser.displayName ?? null,
|
||||
},
|
||||
toPublisherId: transfer.toPublisherId ?? undefined,
|
||||
message: transfer.message,
|
||||
requestedAt: transfer.requestedAt,
|
||||
expiresAt: transfer.expiresAt,
|
||||
@@ -332,29 +441,41 @@ export const listOutgoingInternal = internalQuery({
|
||||
.collect();
|
||||
|
||||
const results: Array<{
|
||||
type: "skill";
|
||||
_id: Id<"skillOwnershipTransfers">;
|
||||
skill: { _id: Id<"skills">; slug: string; displayName: string };
|
||||
toUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toUser?: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toPublisherId?: Id<"publishers">;
|
||||
message: string | undefined;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) continue;
|
||||
if (isTransferExpired(transfer, now)) continue;
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) continue;
|
||||
const toUser = await ctx.db.get(transfer.toUserId);
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) continue;
|
||||
|
||||
let toUser:
|
||||
| { _id: Id<"users">; handle: string | null; displayName: string | null }
|
||||
| undefined;
|
||||
if (transfer.toUserId) {
|
||||
const tu = await ctx.db.get(transfer.toUserId);
|
||||
if (tu && !tu.deletedAt && !tu.deactivatedAt) {
|
||||
toUser = {
|
||||
_id: tu._id,
|
||||
handle: tu.handle ?? null,
|
||||
displayName: tu.displayName ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: "skill" as const,
|
||||
_id: transfer._id,
|
||||
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
|
||||
toUser: {
|
||||
_id: toUser._id,
|
||||
handle: toUser.handle ?? null,
|
||||
displayName: toUser.displayName ?? null,
|
||||
},
|
||||
toUser,
|
||||
toPublisherId: transfer.toPublisherId ?? undefined,
|
||||
message: transfer.message,
|
||||
requestedAt: transfer.requestedAt,
|
||||
expiresAt: transfer.expiresAt,
|
||||
@@ -365,6 +486,22 @@ export const listOutgoingInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPendingTransferBySkillInternal = internalQuery({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const transfer = await ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_skill_status", (q) => q.eq("skillId", args.skillId).eq("status", "pending"))
|
||||
.first();
|
||||
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
|
||||
export const getPendingTransferBySkillAndUserInternal = internalQuery({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
@@ -378,7 +515,7 @@ export const getPendingTransferBySkillAndUserInternal = internalQuery({
|
||||
.filter((q) => q.eq(q.field("toUserId"), args.toUserId))
|
||||
.first();
|
||||
|
||||
if (!transfer || isExpired(transfer, now)) return null;
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
@@ -396,7 +533,7 @@ export const getPendingTransferBySkillAndFromUserInternal = internalQuery({
|
||||
.filter((q) => q.eq(q.field("fromUserId"), args.fromUserId))
|
||||
.first();
|
||||
|
||||
if (!transfer || isExpired(transfer, now)) return null;
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
|
||||
+5
-3
@@ -177,13 +177,15 @@ Stores your API token + cached registry URL.
|
||||
|
||||
### `transfer`
|
||||
|
||||
- Ownership transfer workflow.
|
||||
- Skill ownership transfer workflow.
|
||||
- Supports user-to-user, user-to-org, org-to-user, and org-to-org transfers.
|
||||
- Subcommands:
|
||||
- `transfer request <slug> <handle> [--message "..."] [--yes]`
|
||||
- `transfer request <slug> <handle> [--message "..."] [--publisher @org] [--yes]`
|
||||
- `transfer list [--outgoing]`
|
||||
- `transfer accept <slug> [--yes]`
|
||||
- `transfer accept <slug> [--publisher @org] [--yes]`
|
||||
- `transfer reject <slug> [--yes]`
|
||||
- `transfer cancel <slug> [--yes]`
|
||||
- `--publisher @org`: on request, targets the transfer to an org (recipient must be org admin to accept). On accept, assigns ownership to the org instead of the accepting user's personal publisher.
|
||||
- Endpoints:
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
|
||||
+19
-2
@@ -478,16 +478,33 @@ Notes:
|
||||
|
||||
### Transfer ownership endpoints
|
||||
|
||||
Transfers support user-to-user, user-to-org, org-to-user, and org-to-org flows for skills. Org-targeted transfers require the actor to hold `admin` or `owner` role on the relevant publisher.
|
||||
|
||||
#### Skill transfers
|
||||
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- Body: `{ "toUserHandle": "target_handle", "message": "optional" }`
|
||||
- Body: `{ "toUserHandle": "target_handle", "message": "optional", "toPublisherHandle": "optional_org_handle" }`
|
||||
- When `toPublisherHandle` is provided, the transfer targets the org. The recipient (or any org admin) accepts on behalf of the org.
|
||||
- Response: `{ "ok": true, "transferId": "skillOwnershipTransfers:...", "toUserHandle": "target_handle", "expiresAt": 1730000000000 }`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
- Optional body: `{ "publisherHandle": "org_handle" }` — assign the skill to an org instead of the accepting user's personal publisher.
|
||||
- `POST /api/v1/skills/{slug}/transfer/reject`
|
||||
- `POST /api/v1/skills/{slug}/transfer/cancel`
|
||||
- Response (accept/reject/cancel): `{ "ok": true, "skillSlug": "demo-skill?" }`
|
||||
|
||||
#### Transfer listing
|
||||
|
||||
- `GET /api/v1/transfers/incoming`
|
||||
- `GET /api/v1/transfers/outgoing`
|
||||
- Response shape: `{ "transfers": [{ "_id": "...", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
|
||||
- Returns skill transfers, sorted by `requestedAt` descending.
|
||||
- Response shape: `{ "transfers": [{ "_id": "...", "type": "skill", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
|
||||
|
||||
#### Transfer rules
|
||||
|
||||
- Pending transfers expire after 7 days.
|
||||
- Only one pending transfer per skill at a time.
|
||||
- Ownership is re-validated at accept time; if ownership changed since the request, the transfer is auto-cancelled.
|
||||
- Org transfers require `admin` or `owner` role on the source/target publisher.
|
||||
|
||||
### `POST /api/v1/users/ban`
|
||||
|
||||
|
||||
+6
-9
@@ -325,12 +325,9 @@ Semantics:
|
||||
|
||||
## Transfer Model
|
||||
|
||||
Current transfers are user-to-user only. That is too narrow.
|
||||
|
||||
New transfer target should be a publisher.
|
||||
|
||||
Support:
|
||||
Transfers support publisher-based flows for both skills and packages:
|
||||
|
||||
- user publisher -> user publisher
|
||||
- user publisher -> org publisher
|
||||
- org publisher -> user publisher
|
||||
- org publisher -> org publisher
|
||||
@@ -339,12 +336,12 @@ Transfer acceptance rule:
|
||||
|
||||
- actor must have `owner` or `admin` on target publisher
|
||||
|
||||
Audit should record:
|
||||
Audit records:
|
||||
|
||||
- actor user id
|
||||
- source publisher id
|
||||
- target publisher id
|
||||
- resource id
|
||||
- source publisher id (`fromPublisherId`)
|
||||
- target publisher id (`toPublisherId`)
|
||||
- resource id (skill or package)
|
||||
|
||||
## Search Digest Changes
|
||||
|
||||
|
||||
@@ -503,10 +503,11 @@ const transfer = program.command("transfer").description("Transfer skill ownersh
|
||||
|
||||
transfer
|
||||
.command("request")
|
||||
.description("Request skill transfer to another user")
|
||||
.description("Request skill transfer to another user or organization")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.argument("<handle>", "Recipient handle (e.g., @username)")
|
||||
.option("--message <text>", "Optional message for recipient")
|
||||
.option("--publisher <handle>", "Target org publisher handle")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, handle, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
@@ -524,8 +525,9 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("accept")
|
||||
.description("Accept incoming transfer for a skill")
|
||||
.description("Accept incoming skill transfer")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--publisher <handle>", "Accept to org publisher instead of personal")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
@@ -534,7 +536,7 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("reject")
|
||||
.description("Reject incoming transfer for a skill")
|
||||
.description("Reject incoming skill transfer")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
@@ -544,7 +546,7 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("cancel")
|
||||
.description("Cancel outgoing transfer for a skill")
|
||||
.description("Cancel outgoing skill transfer")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
|
||||
@@ -64,8 +64,10 @@ describe("transfer commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as {
|
||||
body?: Record<string, string>;
|
||||
};
|
||||
expect(requestArgs.body?.toUserHandle).toBe("alice");
|
||||
});
|
||||
|
||||
it("list calls incoming transfers endpoint", async () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function cmdTransferRequest(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
toHandleArg: string,
|
||||
options: ConfirmOptions & { message?: string },
|
||||
options: ConfirmOptions & { message?: string; publisher?: string },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = normalizeSlug(slugArg);
|
||||
@@ -73,7 +73,7 @@ export async function cmdTransferRequest(
|
||||
const confirmed = await requireYesOrConfirm(
|
||||
options,
|
||||
inputAllowed,
|
||||
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
|
||||
`Transfer skill "${slug}" to @${toHandle}? Recipient must accept.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -82,16 +82,21 @@ export async function cmdTransferRequest(
|
||||
const spinner = createSpinner(`Requesting transfer of ${slug} to @${toHandle}`);
|
||||
|
||||
try {
|
||||
const body: Record<string, string | undefined> = {
|
||||
toUserHandle: toHandle,
|
||||
message: options.message,
|
||||
};
|
||||
if (options.publisher) {
|
||||
body.toPublisherHandle = options.publisher.replace(/^@+/, "");
|
||||
}
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer`,
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
toUserHandle: toHandle,
|
||||
message: options.message,
|
||||
}),
|
||||
body,
|
||||
},
|
||||
ApiV1TransferRequestResponseSchema,
|
||||
);
|
||||
@@ -150,11 +155,12 @@ export async function cmdTransferList(opts: GlobalOpts, options: { outgoing?: bo
|
||||
async function runTransferDecision(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: ConfirmOptions,
|
||||
options: ConfirmOptions & { publisher?: string },
|
||||
inputAllowed: boolean,
|
||||
spec: DecisionSpec,
|
||||
) {
|
||||
const slug = normalizeSlug(slugArg);
|
||||
|
||||
const confirmed = await requireYesOrConfirm(
|
||||
options,
|
||||
inputAllowed,
|
||||
@@ -167,12 +173,18 @@ async function runTransferDecision(
|
||||
const spinner = createSpinner(`${spec.progress} transfer of ${slug}`);
|
||||
|
||||
try {
|
||||
const body: Record<string, string> | undefined =
|
||||
spec.action === "accept" && options.publisher
|
||||
? { publisherHandle: options.publisher.replace(/^@+/, "") }
|
||||
: undefined;
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer/${spec.action}`,
|
||||
token,
|
||||
...(body ? { body } : {}),
|
||||
},
|
||||
ApiV1TransferDecisionResponseSchema,
|
||||
);
|
||||
@@ -188,7 +200,7 @@ async function runTransferDecision(
|
||||
export function cmdTransferAccept(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: ConfirmOptions,
|
||||
options: ConfirmOptions & { publisher?: string },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return runTransferDecision(opts, slugArg, options, inputAllowed, DECISION_SPECS.accept);
|
||||
|
||||
Reference in New Issue
Block a user