Files
buzz/desktop/src/features/projects/projectRepositoryCreation.ts
2026-08-31 16:54:06 -04:00

299 lines
10 KiB
TypeScript

import type { RelayEvent } from "@/shared/api/types";
import type { Repository } from "@/features/projects/hooks";
import {
isValidProjectChannelId,
MAX_PROJECT_MEMBERS,
MAX_PROJECT_RELATED_CHANNELS,
PROJECT_RELATED_CHANNEL_TAG,
validateProjectEventEnvelope,
} from "@/features/projects/projectModels";
import {
KIND_PROJECT_ANNOUNCEMENT,
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import type { ProjectEventTemplate } from "./projectCreation";
function repositoryDtagFromName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export { repositoryDtagFromName };
/**
* Creates a project-replacement event template from a live, signed raw head
* (fetched immediately before the mutation). Repository membership is patched,
* and callers with a folded Project model carry its effective related channels
* into the new base. Every other tag and the `content` field are preserved
* verbatim, satisfying NIP-MP's extension-tag preservation rule and preventing
* a cached UI projection from silently erasing unknown tags.
*
* Performs full NIP-MP envelope validation on the patched output via the shared
* `validateProjectEventEnvelope` validator — the same checks applied by the
* read parser — so Desktop's write path agrees with its read path on which
* heads are valid regardless of the relay in use.
*/
function buildProjectPatchTemplate({
liveHead,
ownerPubkey,
relatedChannelIds,
repositoryAddresses,
}: {
liveHead: RelayEvent;
ownerPubkey: string;
relatedChannelIds?: string[];
repositoryAddresses: string[];
}): ProjectEventTemplate {
const normalizedOwner = ownerPubkey.trim().toLowerCase();
if (normalizedOwner !== liveHead.pubkey.toLowerCase()) {
throw new Error("Only the project owner can add repositories.");
}
if (repositoryAddresses.length > MAX_PROJECT_MEMBERS) {
throw new Error(
`A project cannot contain more than ${MAX_PROJECT_MEMBERS} repositories.`,
);
}
if (new Set(repositoryAddresses).size !== repositoryAddresses.length) {
throw new Error("A project cannot contain duplicate repositories.");
}
if (
repositoryAddresses.some(
(address) => !/^30617:[0-9a-f]{64}:.+$/.test(address),
)
) {
throw new Error("Repository address is invalid.");
}
// Replace all existing `a` tags with the new set, preserving everything else
// (d, name, description, buzz-channel, buzz-visibility, relay hints embedded
// in `a` tags, and any future/unknown tags).
const nonMemberTags = liveHead.tags.filter(
(tag) =>
tag[0] !== "a" &&
(relatedChannelIds === undefined ||
tag[0] !== PROJECT_RELATED_CHANNEL_TAG),
);
const existingHints = new Map<string, string>();
for (const tag of liveHead.tags) {
if (tag[0] === "a" && tag[1] && tag[2]) {
existingHints.set(tag[1], tag[2]);
}
}
const memberTags = repositoryAddresses.sort().map((address): string[] => {
const hint = existingHints.get(address);
return hint ? ["a", address, hint] : ["a", address];
});
const homeChannelId = liveHead.tags
.find((tag) => tag[0] === "buzz-channel")?.[1]
?.toLowerCase();
const relatedChannelTags = [
...new Set(
(relatedChannelIds ?? []).map((channelId) =>
channelId.trim().toLowerCase(),
),
),
]
.filter(
(channelId) =>
isValidProjectChannelId(channelId) && channelId !== homeChannelId,
)
.slice(0, MAX_PROJECT_RELATED_CHANNELS)
.map((channelId) => [PROJECT_RELATED_CHANNEL_TAG, channelId]);
const patchedTags = [
...nonMemberTags,
...(relatedChannelIds === undefined ? [] : relatedChannelTags),
...memberTags,
];
const content = liveHead.content;
// Validate the full patched envelope against NIP-MP rules. This catches
// nonconforming live heads (e.g., from a relay that accepted a malformed
// event) before we sign and re-submit, and pins the write path to the same
// spec the read parser enforces: duplicate `d`, duplicate/oversized
// metadata, malformed member arity, and the 64-member boundary.
validateProjectEventEnvelope(patchedTags, content);
return {
kind: KIND_PROJECT_ANNOUNCEMENT,
content,
tags: patchedTags,
};
}
export { buildProjectPatchTemplate };
export function buildRepositoryChannelBindingTemplate({
channelId,
ownerPubkey,
repository,
}: {
channelId: string;
ownerPubkey: string;
repository: Repository;
}): ProjectEventTemplate {
const normalizedChannelId = channelId.trim();
if (ownerPubkey.trim().toLowerCase() !== repository.owner.toLowerCase()) {
throw new Error("Only the repository owner can repair its access.");
}
if (!isValidProjectChannelId(normalizedChannelId)) {
throw new Error("Repository access channel is invalid.");
}
if (!repository.eventTags) {
throw new Error(
"Repository metadata is unavailable. Refresh and try again.",
);
}
return {
kind: KIND_REPO_ANNOUNCEMENT,
content: repository.eventContent ?? repository.description,
tags: [
...repository.eventTags
.filter((tag) => tag[0] !== "buzz-channel")
.map((tag) => [...tag]),
["buzz-channel", normalizedChannelId],
],
};
}
export type AddedRepositoryEventTemplatesFromHead = {
project: ProjectEventTemplate;
repository: ProjectEventTemplate;
repositoryAddress: string;
repositoryDtag: string;
/**
* True when the live head already references the coordinate but the caller
* indicated no repository head exists there (a dangling member from an
* earlier partial publish). The project head is already correct — publish
* only the repository event to heal.
*/
resume: boolean;
};
/**
* Builds the project-replacement + new-repository templates for `addRepo`,
* patching the live signed project head rather than reconstructing from the
* cached UI projection. This is the preferred path: it preserves unknown tags
* and detects concurrent writes before they cause data loss.
*
* The caller is responsible for checking that `liveHead.created_at` is not
* newer than the cached project's `createdAt` + the caller's margin (i.e., a
* dominated-write guard) before using the result.
*/
export function buildAddedRepositoryEventTemplatesFromHead({
accessChannelId,
cloneUrl,
description,
existingRepositoryAddresses,
liveHead,
name,
ownerPubkey,
relatedChannelIds,
repositoryHeadExists = true,
webUrl,
}: {
accessChannelId?: string;
cloneUrl?: string;
description?: string;
existingRepositoryAddresses: string[];
liveHead: RelayEvent;
name: string;
ownerPubkey: string;
relatedChannelIds?: string[];
/**
* Whether a kind-30617 head already exists at the new coordinate. When the
* live project head references the coordinate but no repository head exists
* there, an earlier add-repository publish failed between its two events —
* return resume templates instead of throwing so retry can heal the
* dangling member.
*/
repositoryHeadExists?: boolean;
webUrl?: string;
}): AddedRepositoryEventTemplatesFromHead {
const normalizedOwner = ownerPubkey.trim().toLowerCase();
const normalizedName = name.trim();
if (!normalizedName) throw new Error("Repository name is required.");
const repositoryDtag = repositoryDtagFromName(normalizedName);
if (!repositoryDtag) {
throw new Error("Repository name must include letters or numbers.");
}
const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${repositoryDtag}`;
// Read live membership from the fetched head (not the cached projection).
const liveAddresses = liveHead.tags
.filter((tag) => tag[0] === "a" && tag[1])
.map((tag) => tag[1] as string);
// If the repo is already in the live head with a live repository head at
// the coordinate (race: another session added it), surface that to the
// caller. Without a repository head the membership is a dangling member
// from a partial publish — resume by publishing only the repository event.
const resume =
liveAddresses.includes(repositoryAddress) && !repositoryHeadExists;
if (liveAddresses.includes(repositoryAddress) && repositoryHeadExists) {
throw new Error(
`This project already contains "${repositoryDtag}" (it was added by another session).`,
);
}
// An "unavailable member" is a coordinate already in the project's address
// list (cached projection) but absent from resolved repositories. When this
// happens we keep the existing addresses and add nothing new.
const isUnavailableMember =
existingRepositoryAddresses.includes(repositoryAddress);
const normalizedDescription = description?.trim() ?? "";
const repositoryTags: string[][] = [
["d", repositoryDtag],
["name", normalizedName],
];
const normalizedAccessChannelId = accessChannelId?.trim();
if (!normalizedAccessChannelId) {
throw new Error(
"This project has no repository access channel to inherit.",
);
}
if (!isValidProjectChannelId(normalizedAccessChannelId)) {
throw new Error("Repository access channel is invalid.");
}
repositoryTags.push(["buzz-channel", normalizedAccessChannelId]);
if (normalizedDescription) {
repositoryTags.push(["description", normalizedDescription]);
}
const normalizedCloneUrl = cloneUrl?.trim();
if (normalizedCloneUrl) repositoryTags.push(["clone", normalizedCloneUrl]);
const normalizedWebUrl = webUrl?.trim();
if (normalizedWebUrl) repositoryTags.push(["web", normalizedWebUrl]);
// In resume mode the live head already lists the coordinate; the project
// template is a no-op republish guard and must not double-add the address.
const newAddresses =
isUnavailableMember || resume
? [...liveAddresses]
: [...liveAddresses, repositoryAddress];
const projectTemplate = buildProjectPatchTemplate({
liveHead,
ownerPubkey,
relatedChannelIds,
repositoryAddresses: newAddresses,
});
return {
project: projectTemplate,
repository: {
kind: KIND_REPO_ANNOUNCEMENT,
content: normalizedDescription,
tags: repositoryTags,
},
repositoryAddress,
repositoryDtag,
resume,
};
}