Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c805c16
feat: add packageOwnershipTransfers table and extend skillOwnershipTr…
TommYDeeee Mar 29, 2026
12143da
feat: add shared transfer helpers for ownership validation
TommYDeeee Mar 29, 2026
2e2dab5
feat: extend skill transfers with org support
TommYDeeee Mar 29, 2026
76670e2
feat: add package transfers backend with full test coverage
TommYDeeee Mar 29, 2026
3291bae
feat: update CLI transfer commands to support both skills and packages
TommYDeeee Mar 29, 2026
56d1b84
feat: add package transfer HTTP endpoints and unified transfer listing
TommYDeeee Mar 29, 2026
5dfe2a8
fix: add missing validateTransferAcceptPermission calls and fix type …
TommYDeeee Mar 29, 2026
242cba4
fix: update HTTP handler test for unified transfer listing
TommYDeeee Mar 29, 2026
ff28d69
fix: resolve ownership transfer bugs for org-targeted transfers
TommYDeeee Mar 29, 2026
5f1b4ba
fix: align CLI body field names with HTTP handler expectations
TommYDeeee Mar 29, 2026
a14c3cf
fix: always set toUserId in skill transfers for listing visibility
TommYDeeee Mar 29, 2026
fa637da
docs: update documentation for package transfers and org support
TommYDeeee Mar 29, 2026
f183e3b
Revert "docs: update documentation for package transfers and org supp…
TommYDeeee Mar 29, 2026
a9dfb81
docs: update API, CLI, and org docs for package transfers and org sup…
TommYDeeee Mar 29, 2026
e35b065
docs: clean up transfer model section in orgs.md
TommYDeeee Mar 29, 2026
7d19162
fix: stale ownership check for org transfers and publisher override v…
TommYDeeee Mar 29, 2026
be035b2
fix: allow any org admin to accept/reject transfers via HTTP API
TommYDeeee Mar 29, 2026
ed2f665
fix: allow self-recipient for org-targeted transfers and improve auto…
TommYDeeee Mar 29, 2026
4022b0b
fix: allow non-recipient org admins to accept/reject transfers
TommYDeeee Mar 29, 2026
3d5939f
fix: reject transfers to inactive publishers and error on malformed a…
TommYDeeee Mar 29, 2026
27dfd87
fix: let source-org admins cancel pending transfers
TommYDeeee Mar 29, 2026
19e7951
fix: allow source-org admins to cancel transfers via HTTP API
TommYDeeee Mar 29, 2026
6579af3
fix: prevent false ambiguity in CLI auto-detection
TommYDeeee Mar 29, 2026
517778d
merge: integrate latest main into feat/ownership-transfer
TommYDeeee Apr 8, 2026
dee1ab0
fix: remove double JSON encoding in CLI transfer request and accept
TommYDeeee Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
listSkillsV1Http,
listSoulsV1Http,
packagesGetRouterV1Http,
packagesPostRouterV1Http,
pluginsGetRouterV1Http,
publishSkillV1Http,
publishPackageV1Http,
Expand Down Expand Up @@ -124,6 +125,12 @@ http.route({
handler: publishPackageV1Http,
});

http.route({
pathPrefix: `${ApiRoutes.packages}/`,
method: "POST",
handler: packagesPostRouterV1Http,
});

http.route({
pathPrefix: `${ApiRoutes.skills}/`,
method: "POST",
Expand Down
25 changes: 16 additions & 9 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2090,18 +2090,25 @@ describe("httpApiV1 handlers", () => {
user: { handle: "p" },
} as never);

let transferQueryCount = 0;
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
if ("userId" in args) {
return [
{
_id: "skillOwnershipTransfers:1",
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
fromUser: { _id: "users:2", handle: "alice", displayName: "Alice" },
requestedAt: 100,
expiresAt: 200,
},
];
transferQueryCount++;
// First call is skill transfers, second is package transfers
if (transferQueryCount === 1) {
return [
{
_id: "skillOwnershipTransfers:1",
type: "skill",
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
fromUser: { _id: "users:2", handle: "alice", displayName: "Alice" },
requestedAt: 100,
expiresAt: 200,
},
];
}
return [];
}
return null;
});
Expand Down
3 changes: 3 additions & 0 deletions convex/httpApiV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
listPackagesV1Handler,
listPluginsV1Handler,
packagesGetRouterV1Handler,
packagesPostRouterV1Handler,
pluginsGetRouterV1Handler,
publishPackageV1Handler,
} from "./httpApiV1/packagesV1";
Expand Down Expand Up @@ -33,6 +34,7 @@ export const listPackagesV1Http = httpAction(listPackagesV1Handler);
export const listPluginsV1Http = httpAction(listPluginsV1Handler);
export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
export const packagesPostRouterV1Http = httpAction(packagesPostRouterV1Handler);
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
Expand Down Expand Up @@ -63,6 +65,7 @@ export const __handlers = {
listPackagesV1Handler,
listPluginsV1Handler,
packagesGetRouterV1Handler,
packagesPostRouterV1Handler,
pluginsGetRouterV1Handler,
publishPackageV1Handler,
listCodePluginsV1Handler,
Expand Down
243 changes: 243 additions & 0 deletions convex/httpApiV1/packagesV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
MAX_RAW_FILE_BYTES,
getPathSegments,
json,
parseJsonPayload,
resolveTagsBatch,
requireApiTokenUserOrResponse,
safeTextFileResponse,
Expand All @@ -36,6 +37,7 @@ const apiRefs = api as unknown as {
const internalRefs = internal as unknown as {
packages: {
getByNameForViewerInternal: unknown;
getPackageByNameInternal: unknown;
listPageForViewerInternal: unknown;
searchForViewerInternal: unknown;
listVersionsForViewerInternal: unknown;
Expand All @@ -50,12 +52,30 @@ const internalRefs = internal as unknown as {
getVersionByIdInternal: unknown;
getVersionBySkillAndVersionInternal: unknown;
};
publishers: {
getByHandleInternal: unknown;
};
packageTransfers: {
requestTransferInternal: unknown;
acceptTransferInternal: unknown;
rejectTransferInternal: unknown;
cancelTransferInternal: unknown;
listIncomingInternal: unknown;
listOutgoingInternal: unknown;
getPendingTransferByPackageInternal: unknown;
getPendingTransferByPackageAndUserInternal: unknown;
getPendingTransferByPackageAndFromUserInternal: unknown;
};
};

async function runQueryRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runQuery(ref as never, args as never)) as T;
}

async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runMutation(ref as never, args as never)) as T;
}

async function runActionRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runAction(ref as never, args as never)) as T;
}
Expand Down Expand Up @@ -1118,3 +1138,226 @@ type PublicPackageDocLike = {
createdAt: number;
updatedAt: number;
};

// ---------------------------------------------------------------------------
// Package transfer handlers
// ---------------------------------------------------------------------------

type PackageTransferDecisionAction = "accept" | "reject" | "cancel";

function packageTransferErrorToResponse(error: unknown, headers: HeadersInit) {
const message = error instanceof Error ? error.message : "Transfer failed";
const lower = message.toLowerCase();
if (lower.includes("unauthorized")) return text("Unauthorized", 401, headers);
if (lower.includes("forbidden")) return text("Forbidden", 403, headers);
if (lower.includes("not found")) return text(message, 404, headers);
if (lower.includes("required") || lower.includes("invalid") || lower.includes("pending")) {
return text(message, 400, headers);
}
return text(message, 400, headers);
}

type PackageTransferDocLike = {
_id: Id<"packages">;
softDeletedAt?: number;
};

type PendingTransferLike = {
_id: Id<"packageOwnershipTransfers">;
};

async function resolvePackageTransferContext(
ctx: ActionCtx,
request: Request,
name: string,
headers: HeadersInit,
): Promise<
{ ok: true; userId: Id<"users">; pkg: PackageTransferDocLike } | { ok: false; response: Response }
> {
const auth = await requireApiTokenUserOrResponse(ctx, request, headers);
if (!auth.ok) return auth;

const pkg = await runQueryRef<PackageTransferDocLike | null>(
ctx,
internalRefs.packages.getPackageByNameInternal,
{ name },
);
if (!pkg || pkg.softDeletedAt)
return { ok: false, response: text("Package not found", 404, headers) };

return { ok: true, userId: auth.userId, pkg };
}

async function handlePackageTransferRequest(
ctx: ActionCtx,
request: Request,
name: string,
headers: HeadersInit,
) {
const transferContext = await resolvePackageTransferContext(ctx, request, name, headers);
if (!transferContext.ok) return transferContext.response;

const parsed = await parseJsonPayload(request, headers);
if (!parsed.ok) return parsed.response;

const toUserHandleRaw =
typeof parsed.payload.toUserHandle === "string" ? parsed.payload.toUserHandle.trim() : "";
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 runQueryRef<{ _id: Id<"publishers"> } | null>(
ctx,
internalRefs.publishers.getByHandleInternal,
{ handle: toPublisherHandleRaw },
);
if (!publisher) return text("Publisher not found", 404, headers);
toPublisherId = publisher._id;
}

try {
const result = await runMutationRef(
ctx,
internalRefs.packageTransfers.requestTransferInternal,
{
actorUserId: transferContext.userId,
packageId: transferContext.pkg._id,
toUserHandle: toUserHandleRaw,
toPublisherId,
message,
},
);
return json(result, 200, headers);
} catch (error) {
return packageTransferErrorToResponse(error, headers);
}
}

async function handlePackageTransferDecision(
ctx: ActionCtx,
request: Request,
name: string,
decision: PackageTransferDecisionAction,
headers: HeadersInit,
) {
const transferContext = await resolvePackageTransferContext(ctx, request, name, headers);
if (!transferContext.ok) return transferContext.response;

let pendingTransfer: PendingTransferLike | null;
if (decision === "cancel") {
pendingTransfer = await runQueryRef<PendingTransferLike | null>(
ctx,
internalRefs.packageTransfers.getPendingTransferByPackageAndFromUserInternal,
{
packageId: transferContext.pkg._id,
fromUserId: transferContext.userId,
},
);
} else {
// Try user-specific lookup first, then fall back to any pending transfer
// for the package (allows org admins other than toUserId to accept/reject)
pendingTransfer = await runQueryRef<PendingTransferLike | null>(
ctx,
internalRefs.packageTransfers.getPendingTransferByPackageAndUserInternal,
{
packageId: transferContext.pkg._id,
toUserId: transferContext.userId,
},
);
if (!pendingTransfer) {
pendingTransfer = await runQueryRef<PendingTransferLike | null>(
ctx,
internalRefs.packageTransfers.getPendingTransferByPackageInternal,
{ packageId: transferContext.pkg._id },
);
}
}
if (!pendingTransfer) return text("No pending transfer found", 404, headers);

const mutation =
decision === "accept"
? internalRefs.packageTransfers.acceptTransferInternal
: decision === "reject"
? internalRefs.packageTransfers.rejectTransferInternal
: internalRefs.packageTransfers.cancelTransferInternal;

try {
// For accept, resolve optional publisher handle to forward publisherId
let publisherId: Id<"publishers"> | undefined;
if (decision === "accept") {
const contentType = request.headers.get("content-type") ?? "";
const hasBody = contentType.includes("json");
const parsed = hasBody ? await parseJsonPayload(request, headers) : null;
if (parsed && !parsed.ok) return parsed.response;
if (parsed?.ok) {
const publisherHandleRaw =
typeof parsed.payload.publisherHandle === "string"
? parsed.payload.publisherHandle.trim()
: "";
if (publisherHandleRaw) {
const publisher = await runQueryRef<{ _id: Id<"publishers"> } | null>(
ctx,
internalRefs.publishers.getByHandleInternal,
{ handle: publisherHandleRaw },
);
if (!publisher) return text("Publisher not found", 404, headers);
publisherId = publisher._id;
}
}
}

const mutationArgs: Record<string, unknown> = {
actorUserId: transferContext.userId,
transferId: pendingTransfer._id,
};
if (publisherId) {
mutationArgs.publisherId = publisherId;
}

const result = await runMutationRef(ctx, mutation, mutationArgs);
return json(result, 200, headers);
} catch (error) {
return packageTransferErrorToResponse(error, headers);
}
}

async function handlePackagesTransferPost(
ctx: ActionCtx,
request: Request,
segments: string[],
headers: HeadersInit,
) {
const name = segments[0]?.trim().toLowerCase() ?? "";
if (!name) return text("Package name required", 400, headers);

if (segments.length === 2) {
return handlePackageTransferRequest(ctx, request, name, headers);
}
if (segments.length === 3) {
const decision = segments[2]?.trim().toLowerCase();
if (decision === "accept" || decision === "reject" || decision === "cancel") {
return handlePackageTransferDecision(ctx, request, name, decision, headers);
}
}
return text("Not found", 404, headers);
}

export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;

const segments = getPathSegments(request, "/api/v1/packages/");
const action = segments[1] ?? "";

if (action === "transfer") {
return handlePackagesTransferPost(ctx, request, segments, rate.headers);
}

return text("Not found", 404, rate.headers);
}
Loading