// @ts-check
/**
* Share service - repository/directory share objects belonging to a project.
*
* Storage model (target):
* - ObjectBase row, Type='repositoryShare'|'directoryShare'
* - `UIDBelongsTo` = own UID (base share) or the base share UID (derivative).
* It carries **no** organization: that is derived over the project chain.
* - Data JSON: { metadata } (mode, required, gitUrl, repositoryKey, …)
* - Two links, both pointing **away** from the share (like a list):
* 'memberA' Share -> Projekt this project uses the share
* 'member' Share -> Person the owner (creator)
*
* Migration tolerance: rows written by the old code still carry
* `UIDBelongsTo` = org and a `memberA`/`member` link **from the project to the
* share**. Every read tolerates both directions; every write produces the new
* shape only.
*
* The wire format follows the App-Standard: UID / Type / project_uid / metadata
* plus `linkType` ('read'|'write'). `linkType` is **derived** from the share's
* `metadata.mode` (readOnly -> 'read'), because the write/read split now lives on
* the share object instead of the link. Requests may still send `linkType`
* ('memberA'|'member'|'write'|'read') as a legacy alias for that setting.
*
* @import {ExpressRequestAuthorized} from '../../types.js'
*/
import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { isAdmin, isListAdmin, isObjectAdmin, isObjectVisible } from '../../utils/authChecks.js';
import { apiError } from '../../utils/apiEnvelope.js';
import { SHARE_TYPES } from '../../utils/projectContract.js';
import { PROJECT_EVENTS_ENABLED } from '../../config/featureFlags.js';
import { writeEventLog, publishToRedis, buildEventPayload } from '../../utils/events.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
import { getOrganizationForShare } from '../../utils/organizationUtils.js';
import { mirrorProjectFilters, reconcileShare, rootOfShare, projectShareRoots, hasDerivedShares, promoteRoot, projectsOfShares, dependentsOfRoots, SHARE_ROOT_SQL, SHARE_IS_BASE_SQL } from './access.js';
import { loadVisibleScopes, publishVisibilityDiff } from '../../tree/visibilityEvents.js';
const SHARE_TYPES_SQL = `'repositoryShare','directoryShare'`;
/**
* Link types that connect a share with its project. The target model has exactly
* one (`memberA`); `member` is tolerated while old rows exist. The owner link
* (share -> person) uses the same type but a different target type (person), so
* it is never matched by these predicates.
*/
const PROJECT_LINK_TYPES = ['memberA', 'member'];
/** @type {const} */
const PROJECT_LINK_TYPES_SQL = `'memberA','member'`;
/** Accepted `linkType` values in requests (legacy alias for the share mode). */
const LINK_TYPES = ['memberA', 'member', 'write', 'read'];
/** Stored `metadata.mode` values that mean "read only". `reference` is the legacy spelling. */
const READ_ONLY_MODES = ['readonly', 'reference'];
const MODE_READ_ONLY = 'readOnly';
const MODE_EDITABLE = 'editable';
/**
* Canonical `metadata.mode`. Tolerates case/whitespace and the legacy
* `reference` spelling, which stays a **block** (fail-closed).
* @param {unknown} mode
* @returns {string}
*/
const normalizeMode = (mode) => {
const value = String(mode ?? '').trim().toLowerCase();
return READ_ONLY_MODES.includes(value) ? MODE_READ_ONLY : MODE_EDITABLE;
};
/**
* Is this share write-locked? (`metadata.mode` = readOnly / reference)
* @param {Object} metadata
*/
const isReadOnly = (metadata) => normalizeMode(metadata?.mode) === MODE_READ_ONLY;
/**
* Wire `linkType` derived from the share mode — the former write/read link level
* is now a property of the share.
* @param {Object} metadata
* @returns {'read'|'write'}
*/
const levelOf = (metadata) => (isReadOnly(metadata) ? 'read' : 'write');
/**
* Resolve an incoming `linkType` into a share mode. `memberA`/`write` mean
* editable, `member`/`read` mean read only — that is the faithful translation of
* the old link level into the new share property.
* @param {unknown} linkType
* @returns {string|null} the mode, or null for an unknown value
*/
const modeFromLinkType = (linkType) => {
const value = String(linkType ?? '').trim();
if (value === 'memberA' || value === 'write') return MODE_EDITABLE;
if (value === 'member' || value === 'read') return MODE_READ_ONLY;
return null;
};
/**
* Normalisiert eine Git-URL fuer den **Hinweis**-Vergleich (nie fuer eine
* Identitaet — die ist die UID-Kette). Protokoll, `git@host:`-Praefix,
* `.git`-Suffix, Trailing-Slash und Gross-/Kleinschreibung fallen weg.
*
* @param {unknown} url
* @returns {string} leer, wenn keine URL brauchbar ist
*/
const normalizeGitUrl = (url) => {
let value = String(url ?? '').trim().toLowerCase();
if (!value) return '';
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, ''); // https:// git:// ssh://
value = value.replace(/^[^@/]+@/, ''); // git@host:pfad
value = value.replace(/^([^/:]+):(?!\/)/, '$1/'); // host:pfad -> host/pfad
value = value.replace(/\.git$/, '');
value = value.replace(/\/+$/, '');
return value;
};
/**
* Nicht blockierender Hinweis: ein **anderer** Root derselben Organisation
* traegt dieselbe Git-URL. Das ist ein Hinweis fuer die Oberflaeche („meintest
* du ‚bestehendes hinzufuegen'?") — **keine** Regel: dieselbe URL kann legitim
* zweimal existieren (Fork, zweiter Klon), und die Identitaet ist die UID, nie
* die URL.
*
* @param {string|Buffer} rootUid
* @param {unknown} gitUrl
* @param {string} orgUuid
* @returns {Promise<Array<{code: string, share_uid: string, repositoryKey: string|null}>>}
*/
const duplicateGitUrlWarnings = async (rootUid, gitUrl, orgUuid) => {
const key = normalizeGitUrl(gitUrl);
if (!key) return [];
const rows = await query(
`SELECT ObjectBase.UID, ObjectBase.Title,
${SHARE_ROOT_SQL('ObjectBase')} AS root,
JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.gitUrl')) AS gitUrl
FROM ObjectBase
WHERE ObjectBase.Type IN (${SHARE_TYPES_SQL})
AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.gitUrl')) IS NOT NULL`,
).catch(() => []);
const selfKey = HEX2uuid(rootUid);
const seen = new Set([selfKey]);
const warnings = [];
for (const row of rows) {
const rootKey = HEX2uuid(row.root);
if (seen.has(rootKey)) continue;
seen.add(rootKey);
if (normalizeGitUrl(row.gitUrl) !== key) continue;
if (await getOrganizationForShare(row.UID) !== orgUuid) continue;
warnings.push({ code: 'DUPLICATE_GIT_URL', share_uid: rootKey, repositoryKey: row.Title ?? null });
}
return warnings;
};
/**
* Verify the project exists in the organization and the user may **change** it
* (Visible `changeable` or `admin`). Changeable is enough to create or link
* shares — the creator becomes the owner of the new share.
* @param {ExpressRequestAuthorized} req
* @param {string} projectHex
* @returns {Promise<void>}
*/
const requireProjectChangeable = async (req, projectHex) => {
if (!(await req.session?.root)) throw apiError(401, 'UNAUTHENTICATED', 'Not authenticated');
const [project] = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type='project'`,
[projectHex],
);
if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found');
if (!(await isObjectAdmin(req, projectHex))) {
throw apiError(403, 'PROJECT_NOT_CHANGEABLE', 'Project is not changeable by this user');
}
};
/**
* Verify the project is at least visible to the user (share read is derived
* from the project right).
* @param {ExpressRequestAuthorized} req
* @param {string} projectHex
* @returns {Promise<void>}
*/
const requireProjectVisible = async (req, projectHex) => {
const [project] = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type='project'`,
[projectHex],
);
if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found');
if (!(await isObjectVisible(req, projectHex))) {
throw apiError(403, 'PROJECT_NOT_ACCESSIBLE', 'Project is not accessible');
}
};
/**
* Verify the user may administer this share: project `admin` (inherited) or
* `Visible.admin` on the share itself (the owner).
* @param {ExpressRequestAuthorized} req
* @param {string} projectHex
* @param {string} shareHex
* @returns {Promise<void>}
*/
const requireShareAdmin = async (req, projectHex, shareHex) => {
if (!(await isListAdmin(req, projectHex))) {
const rows = await query(
`SELECT UID FROM Visible WHERE UID=? AND UIDUser=? AND Type='admin'`,
[shareHex, UUID2hex(req.session.user)],
);
if (rows.length === 0) {
throw apiError(403, 'SHARE_NOT_ADMIN', 'Share is not changeable by this user');
}
}
};
/**
* Das Share-Objekt, das **dieses** Projekt fuer einen Root benutzt.
*
* Ein Aufrufer darf den Root nennen (den die Suche vor dem Ableger-Modell
* zeigte) oder das Projekt-Objekt selbst (Basis/Ableger) — beides muss denselben
* Treffer geben. Die harte Regel „ein Root hoechstens einmal je Projekt" macht
* die Auflösung eindeutig, deshalb ist sie kein Raten.
*
* @param {string} projectHex
* @param {string} shareHex
* @returns {Promise<string|null>} die UID des Projekt-Objekts
*/
const resolveProjectShare = async (projectHex, shareHex) => {
const wanted = HEX2uuid(shareHex);
const roots = await projectShareRoots(projectHex);
const exact = roots.find((r) => HEX2uuid(r.share) === wanted);
if (exact) return exact.share;
const target = await rootOfShare(shareHex);
if (!target) return null;
const rootKey = HEX2uuid(target.root);
const byRoot = roots.find((r) => HEX2uuid(r.root) === rootKey);
return byRoot ? byRoot.share : null;
};
/**
* Read a share row that is linked to the given project. Returns the share with
* its link type, or null when the share does not exist or has no link to this
* project. Both link directions are accepted during the migration.
* @param {string} shareHex
* @param {string} projectHex
*/
const readShare = async (shareHex, projectHex) => {
const rows = await query(
`SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo, ObjectBase.Data,
ObjectBase.Title,
Links.Type AS link_type,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM ObjectBase
LEFT JOIN Links ON Links.Type IN (${PROJECT_LINK_TYPES_SQL})
AND (
(Links.UID=? AND Links.UIDTarget=ObjectBase.UID) -- alt: Projekt -> Share
OR (Links.UID=ObjectBase.UID AND Links.UIDTarget=?) -- neu: Share -> Projekt
)
WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${SHARE_TYPES_SQL})
ORDER BY (Links.Type='memberA') DESC`,
[projectHex, projectHex, shareHex],
{ cast: ['json'] },
);
return rows[0] || null;
};
/**
* Map a share row to the app Share object (UID / Type / project_uid / metadata
* / linkType). `project_uid` comes from the project context; the organization is
* no longer readable from the share itself.
* @param {any} row
* @param {string} [projectUid]
*/
export const toShare = (row, projectUid) => {
let metadata = {};
try {
metadata = row.Data && typeof row.Data === 'object' ? row.Data.metadata ?? {} : {};
} catch (e) {
metadata = {};
}
return {
UID: HEX2uuid(row.UID),
project_uid: projectUid || null,
Type: row.Type,
linkType: levelOf(metadata),
metadata,
source_updated_at: row.source_updated_at,
};
};
/**
* Add a share to a project. Requires **changeable** on the project. Creates the
* share as a base share (`UIDBelongsTo` = own UID), links it to the project
* (`memberA`) and records the creator as owner (`member` link + `Visible.admin`).
*
* Legacy `linkType` in the body (`member`/`read`) is translated into
* `metadata.mode = readOnly`.
*
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @returns {Promise<Object>}
*/
export const addShare = async (req, projectUid) => {
try {
const projectHex = UUID2hex(projectUid);
const userHex = UUID2hex(req.session.user);
await requireProjectChangeable(req, projectHex);
const body = req.body || {};
const type = body.type;
if (!SHARE_TYPES.includes(type)) {
throw apiError(422, 'INVALID_SHARE_TYPE', `Share type must be one of: ${SHARE_TYPES.join(', ')}`);
}
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
// contract: share metadata never contains an ACL or secret values
if (metadata.acl !== undefined || metadata.capabilities !== undefined) {
throw apiError(422, 'INVALID_SHARE_METADATA', 'Share metadata must not carry an ACL or capabilities');
}
// Legacy `linkType` from the migration-era UI: translate into the share mode.
if (typeof body.linkType !== 'undefined') {
const legacyMode = modeFromLinkType(body.linkType);
if (legacyMode === null) {
throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
}
if (typeof metadata.mode === 'undefined') metadata.mode = legacyMode;
}
const UID = await getUID(req);
const title = typeof metadata.repositoryKey === 'string' && metadata.repositoryKey ? metadata.repositoryKey : 'share';
const events = [];
// Vorher-Stand der Rechte-Zeilen: beim Anlegen eines Shares leer, aber
// gemessen statt angenommen.
const beforeOwner = await loadVisibleScopes([UID]);
await transaction(async (connection) => {
// Base share: UIDBelongsTo points at itself (the object rule for base objects).
await connection.query(
`INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[UID, type, UID, title, title, title, 0, JSON.stringify({ metadata }), userHex],
);
// New direction: the share points at its project (like a list points at its owner group).
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
[UID, projectHex, userHex],
);
// Owner: the creator owns the share (like a list's `member` link to its creator).
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'member', ?, ?)`,
[UID, userHex, userHex],
);
await connection.query(
`INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
[UID, userHex],
);
// Rechte-Vererbung: denselben Filter-Satz wie das Projekt spiegeln.
// `rebuildListAccess` materialisiert daraus die Visible-Zeilen und
// meldet die Rechte-Events (siehe ./access.js).
await mirrorProjectFilters(projectHex, UID, connection);
if (PROJECT_EVENTS_ENABLED) {
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
await writeEventLog(`/add/${type}/${HEX2uuid(UID)}`, payload, connection);
events.push({ key: `/add/${type}/${HEX2uuid(UID)}`, payload });
// Project link (memberA -> write level event for the projection)
const linkPayload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
await writeEventLog(`/add/project/write/${HEX2uuid(projectHex)}`, linkPayload, connection);
events.push({ key: `/add/project/write/${HEX2uuid(projectHex)}`, payload: linkPayload });
}
});
for (const ev of events) {
publishToRedis(ev.key, ev.payload);
}
// Die Owner-Zeile entsteht **in** der Transaktion. `reconcileShare` legt
// den Rebuild nur in die Queue, und der liest seinen Vorher-Stand erst
// dann — die Zeile steht also schon darin und der Rebuild meldet sie
// niemals. Ohne diese Meldung hat der Anleger in der Projektion kein
// Recht an seinem eigenen Share, sobald ihm die gespiegelten
// Projekt-Filter nichts geben (siehe 057 §16.3).
//
// Nach dem Lifecycle-Event (`/add/{shareType}/{uid}`), damit die
// Projektion den Share kennt, bevor das Recht darauf kommt.
publishVisibilityDiff(beforeOwner, await loadVisibleScopes([UID]), req.session.root);
// Rechte materialisieren (asynchron): Owner + geerbte Projekt-Rechte +
// Rechte-Events fuer die Projektion.
await reconcileShare(req, UID);
const row = await readShare(UID, projectHex);
// Nicht blockierender Hinweis: traegt ein **anderer** Root derselben
// Organisation dieselbe Git-URL, ist das ein Vorschlag fuer die
// Oberflaeche („bestehendes hinzufuegen"), keine Regel. Die Identitaet
// ist die UID, nie die URL — deshalb wird hier nichts abgewiesen.
const warnings = await duplicateGitUrlWarnings(UID, metadata.gitUrl, HEX2uuid(req.session.root));
const result = row
? toShare(row, HEX2uuid(projectHex))
: { UID: HEX2uuid(UID), project_uid: HEX2uuid(projectHex), Type: type, linkType: levelOf(metadata), metadata };
return { success: true, result: { ...result, warnings } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Search org-owned repository/directory shares the current user may see.
*
* Visibility is derived from the projects a share is linked to: a share is
* returned when at least one of its linked projects is visible to the user
* (admin users see all org shares). Because a share carries no organization
* itself, the result is additionally scoped to the caller's organization via
* the project chain.
*
* @param {ExpressRequestAuthorized} req
* @param {string} [searchQuery] - terms (SearchSelect sends "+portal* +back*")
* @param {string} [excludeProjectUid] - skip shares already linked to this project
* @returns {Promise<Object>}
*/
export const searchShares = async (req, searchQuery, excludeProjectUid) => {
try {
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const admin = await isAdmin(req.session);
// Boolean-Mode-Syntax der SearchSelect-Komponente in LIKE-Terme wandeln
const terms = String(searchQuery || '')
.replace(/[+*@"]/g, ' ')
.split(/\s+/)
.filter((t) => t.length > 0)
.slice(0, 8);
const params = [];
let visibilityJoin = '';
if (!admin) {
// A share is visible when at least one project it is linked to is
// visible to the user. The link may point either way (migration):
// new = share -> project, legacy = project -> share.
visibilityJoin = `JOIN Links AS vis ON (
(vis.UIDTarget=ObjectBase.UID AND vis.Type IN (${PROJECT_LINK_TYPES_SQL}))
OR (vis.UID=ObjectBase.UID AND vis.Type='memberA')
)
JOIN Visible ON (Visible.UID=IF(vis.UIDTarget=ObjectBase.UID, vis.UID, vis.UIDTarget) AND Visible.UIDUser=?)`;
params.push(userHex);
}
const where = [`ObjectBase.Type IN (${SHARE_TYPES_SQL})`];
// Ein Root hoechstens einmal je Projekt: alles ausschliessen, dessen Root
// im Zielprojekt schon vertreten ist — egal ob als Basis oder als
// Ableger. Sonst bote die Suche ein Repo an, das das Projekt schon hat.
if (excludeProjectUid) {
const present = await projectShareRoots(UUID2hex(excludeProjectUid));
if (present.length > 0) {
where.push(`NOT ((${SHARE_ROOT_SQL('ObjectBase')}) IN (${present.map(() => '?').join(', ')}))`);
params.push(...present.map((r) => r.root));
}
}
if (terms.length > 0) {
const likeClauses = terms
.map(
() =>
`(ObjectBase.Title LIKE ?
OR ObjectBase.Display LIKE ?
OR JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.repositoryKey')) LIKE ?
OR JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.gitUrl')) LIKE ?)`
)
.join('\n AND ');
where.push(`(${likeClauses})`);
for (const t of terms) {
const like = `%${t}%`;
params.push(like, like, like, like);
}
}
const rows = await query(
`SELECT DISTINCT ObjectBase.UID, ObjectBase.Type, ObjectBase.Title, ObjectBase.Display, ObjectBase.Data,
${SHARE_ROOT_SQL('ObjectBase')} AS root,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM ObjectBase
${visibilityJoin}
WHERE ${where.join('\n AND ')}
ORDER BY ObjectBase.Display, ObjectBase.SortName
LIMIT 50`,
params,
{ cast: ['json'] },
);
// Org-Scope ueber die Projekt-Kette **und** ein Treffer je Root: mit
// Ablegern liegen Basis und Ableger sonst als mehrere Zeilen desselben
// Repos in der Liste. Die Basis gewinnt (stabile Anzeige).
const byRoot = new Map();
for (const row of rows) {
if (await getOrganizationForShare(row.UID) !== HEX2uuid(orgHex)) continue;
const key = HEX2uuid(row.root);
const prev = byRoot.get(key);
const rowIsBase = HEX2uuid(row.UID) === key;
if (!prev || (rowIsBase && HEX2uuid(prev.UID) !== key)) byRoot.set(key, row);
}
return {
success: true,
result: [...byRoot.values()].map((row) => {
const share = toShare(row);
const metadata = share.metadata || {};
const title =
(typeof metadata.repositoryKey === 'string' && metadata.repositoryKey) ||
row.Title ||
row.Display ||
share.UID;
return { ...share, project_uid: null, title, value: share.UID, root_uid: HEX2uuid(row.root) };
}),
};
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* List all shares of a project (requires at least visible on the project).
* Shares are resolved through the project's memberA/member links — in both
* directions while the old rows still exist.
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @returns {Promise<Object>}
*/
export const listShares = async (req, projectUid) => {
try {
const projectHex = UUID2hex(projectUid);
await requireProjectVisible(req, projectHex);
const rows = await query(
`SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo, ObjectBase.Data,
${SHARE_ROOT_SQL('ObjectBase')} AS root,
${SHARE_IS_BASE_SQL('ObjectBase')} AS is_base,
Links.Type AS link_type,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM ObjectBase
JOIN Links ON Links.Type IN (${PROJECT_LINK_TYPES_SQL})
AND (
(Links.UID=? AND Links.UIDTarget=ObjectBase.UID) -- alt: Projekt -> Share
OR (Links.UID=ObjectBase.UID AND Links.UIDTarget=?) -- neu: Share -> Projekt
)
WHERE ObjectBase.Type IN (${SHARE_TYPES_SQL})
GROUP BY ObjectBase.UID
ORDER BY ObjectBase.Title`,
[projectHex, projectHex],
{ cast: ['json'] },
);
// Herkunft und Abhaengige in je einer Abfrage fuer die ganze Liste:
// „wo kommt dieser Ableger her" (Projekt der Basis) und „welche Projekte
// haengen an dieser Basis". Beides braucht die Oberflaeche, und beides
// gehoert hierher, nicht in den Client (der kennt nur dieses Projekt).
const rootUids = [...new Set(rows.map((r) => r.root))];
const roots = rows.filter((r) => r.root && HEX2uuid(r.root) === HEX2uuid(r.UID)).map((r) => r.UID);
const [rootProjects, dependents] = await Promise.all([
projectsOfShares(rootUids),
dependentsOfRoots(roots),
]);
const projectOfRoot = new Map(rootProjects.map((p) => [HEX2uuid(p.share), { UID: HEX2uuid(p.project), Title: p.title || p.display || '' }]));
const dependentsOf = new Map();
for (const d of dependents) {
const key = HEX2uuid(d.root);
if (!dependentsOf.has(key)) dependentsOf.set(key, []);
dependentsOf.get(key).push({ share_uid: HEX2uuid(d.share), type: d.kind, project_uid: d.project ? HEX2uuid(d.project) : null, project_title: d.title || d.display || null });
}
return {
success: true,
result: rows.map((r) => {
const share = toShare(r, HEX2uuid(projectHex));
const rootKey = HEX2uuid(r.root);
const isRoot = Boolean(r.is_base);
const origin = isRoot ? null : projectOfRoot.get(rootKey) || null;
return {
...share,
root_uid: rootKey,
is_root: isRoot,
// Wo der Ableger herkommt: das Projekt der Basis. `null`,
// wenn dieses Objekt selbst die Basis ist (oder die Basis in
// keinem Projekt mehr haengt).
origin: isRoot ? null : projectOfRoot.get(rootKey) || null,
// Nur an der Basis sinnvoll: die Projekte, die davon abhaengen.
dependents: isRoot ? dependentsOf.get(rootKey) || [] : [],
};
}),
};
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Update share metadata (requires admin on the project or `Visible.admin` on
* the share, i.e. the owner).
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @param {string} shareUid
* @returns {Promise<Object>}
*/
export const updateShare = async (req, projectUid, shareUid) => {
try {
const projectHex = UUID2hex(projectUid);
const shareHex = UUID2hex(shareUid);
const userHex = UUID2hex(req.session.user);
await requireProjectChangeable(req, projectHex);
const existing = await readShare(shareHex, projectHex);
if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
await requireShareAdmin(req, projectHex, shareHex);
const body = req.body || {};
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
? body.metadata
: (existing.Data?.metadata ?? {});
if (metadata.acl !== undefined || metadata.capabilities !== undefined) {
throw apiError(422, 'INVALID_SHARE_METADATA', 'Share metadata must not carry an ACL or capabilities');
}
// Legacy `linkType` acts as an alias for the mode (see addShare).
if (typeof body.linkType !== 'undefined') {
const legacyMode = modeFromLinkType(body.linkType);
if (legacyMode === null) {
throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
}
if (typeof metadata.mode === 'undefined') metadata.mode = legacyMode;
}
const title = typeof metadata.repositoryKey === 'string' && metadata.repositoryKey ? metadata.repositoryKey : existing.Title || 'share';
const events = [];
await transaction(async (connection) => {
await connection.query(
`UPDATE ObjectBase SET Title=?, Display=?, SortName=?, Data=?, UIDuser=?
WHERE UID=? AND Type IN (${SHARE_TYPES_SQL})`,
[title, title, title, JSON.stringify({ metadata }), userHex, shareHex],
);
if (PROJECT_EVENTS_ENABLED) {
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
await writeEventLog(`/change/${existing.Type}/${HEX2uuid(shareHex)}`, payload, connection);
events.push({ key: `/change/${existing.Type}/${HEX2uuid(shareHex)}`, payload });
}
});
for (const ev of events) {
publishToRedis(ev.key, ev.payload);
}
const row = await readShare(shareHex, projectHex);
return { success: true, result: row ? toShare(row, HEX2uuid(projectHex)) : existing };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Eine Zuordnung aus einem Projekt entfernen.
*
* Ist das Objekt die **Basis** („Root") und haengen Ableger daran, wird **nicht
* stillschweigend** die Basis behalten: der Aufrufer bestimmt mit
* `successor` (`body.successor` oder `?successor=`), **welcher Ableger die neue
* Basis wird** — also in welches Projekt der Root wandert. Ohne diese Angabe
* antwortet der Endpunkt mit `409 SHARE_ROOT_NEEDS_SUCCESSOR` und der Liste der
* moeglichen Nachfolger, damit die Oberflaeche waehlen kann.
*
* Der Aufruf bleibt idempotent-tolerant: zeigt der `successor` nicht auf einen
* Ableger dieses Roots, ist das ein `422 INVALID_SUCCESSOR`.
*
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @param {string} shareUid
* @returns {Promise<Object>}
*/
export const deleteShare = async (req, projectUid, shareUid) => {
try {
const projectHex = UUID2hex(projectUid);
await requireProjectChangeable(req, projectHex);
// Der Aufrufer darf den Root nennen (Alt-Client) oder das Projekt-Objekt
// (Ableger) — die harte Root-Regel macht die Aufloesung eindeutig.
const shareHex = await resolveProjectShare(projectHex, UUID2hex(shareUid));
if (!shareHex) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
const existing = await readShare(shareHex, projectHex);
if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
await requireShareAdmin(req, projectHex, shareHex);
const linkType = existing.link_type === 'memberA' ? 'write' : 'read';
// Root-Umzug: wer beerbt diese Basis? Nur noetig, wenn sie wirklich eine
// Basis **mit** abhaengigen Ablegern ist.
const rootInfo = await rootOfShare(shareHex);
/** @type {Array<any>} */
let dependents = [];
let successorHex = null;
if (rootInfo && rootInfo.isBase) {
dependents = await dependentsOfRoots([shareHex]);
if (dependents.length > 0) {
const requested = String(req.body?.successor ?? req.query?.successor ?? '').trim();
const matches = requested ? dependents.find((d) => HEX2uuid(d.share) === requested) : null;
if (!requested) {
throw apiError(409, 'SHARE_ROOT_NEEDS_SUCCESSOR', 'This base repository has dependent copies — choose which one becomes the new base', {
root_uid: HEX2uuid(shareHex),
dependents: dependents.map((d) => ({
share_uid: HEX2uuid(d.share),
project_uid: HEX2uuid(d.project),
project_title: d.title || d.display || null,
})),
});
}
if (!matches) {
throw apiError(422, 'INVALID_SUCCESSOR', 'successor must be one of the dependent shares of this repository');
}
successorHex = matches.share;
}
}
const events = [];
await transaction(async (connection) => {
// Erst den Root umziehen (Nachfolger wird Basis, die anderen folgen),
// dann die Zuordnung loesen — so ist der Root nie kurzzeitig ohne
// Basis.
if (successorHex) {
const repointed = await promoteRoot(successorHex, shareHex, connection);
if (PROJECT_EVENTS_ENABLED) {
// Ein Lifecycle-Event je umgehaengtem Objekt: rag-sync liest
// `root_uid` beim Objekt-Read neu, das ist die Index-Identitaet.
for (const rep of [successorHex, ...repointed]) {
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(rep)] });
await writeEventLog(`/add/${existing.Type}/${HEX2uuid(rep)}`, payload, connection);
events.push({ key: `/add/${existing.Type}/${HEX2uuid(rep)}`, payload });
}
}
}
// Remove the project link in both directions (legacy rows).
await connection.query(
`DELETE Links FROM Links
WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
AND ((UID=? AND UIDTarget=?) OR (UID=? AND UIDTarget=?))`,
[projectHex, shareHex, shareHex, projectHex],
);
if (PROJECT_EVENTS_ENABLED) {
const linkPayload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
await writeEventLog(`/remove/project/${linkType}/${HEX2uuid(projectHex)}`, linkPayload, connection);
events.push({ key: `/remove/project/${linkType}/${HEX2uuid(projectHex)}`, payload: linkPayload });
}
// Only remove the object when no project links it any more **and** no
// Ableger depends on it: a base share stays as long as one of its
// Ableger needs it (then it simply has no project of its own). The
// owner link (`member` to a person) does not count as a project link.
const [{ n }] = await connection.query(
`SELECT COUNT(*) AS n FROM Links
WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
AND ((UID=? AND UIDTarget IN (SELECT UID FROM ObjectBase WHERE Type='project'))
OR (UIDTarget=? AND UID IN (SELECT UID FROM ObjectBase WHERE Type='project')))`,
[shareHex, shareHex],
);
if (Number(n ?? 0) === 0 && !(await hasDerivedShares(shareHex, connection))) {
await connection.query(`DELETE FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES_SQL})`, [shareHex]);
await connection.query(`DELETE FROM Visible WHERE UID=?`, [shareHex]);
await connection.query(`DELETE Links FROM Links WHERE UID=? OR UIDTarget=?`, [shareHex, shareHex]);
if (PROJECT_EVENTS_ENABLED) {
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
await writeEventLog(`/remove/${existing.Type}/${HEX2uuid(shareHex)}`, payload, connection);
events.push({ key: `/remove/${existing.Type}/${HEX2uuid(shareHex)}`, payload });
}
}
});
for (const ev of events) {
publishToRedis(ev.key, ev.payload);
}
const objectRemoved = events.some((e) => e.key.startsWith(`/remove/${existing.Type}/`));
// Wurde das Objekt **behalten** (weil ein Ableger es als Basis braucht),
// hat es jetzt kein Projekt mehr: die geerbten Rechte-Zeilen muessen weg,
// sonst traegt der Bestands-Root sie weiter in die Projektion. Der Owner
// bleibt — seine Zeile ist nicht projekt-abgeleitet.
if (!objectRemoved) await reconcileShare(req, shareHex);
return {
success: true,
result: {
UID: HEX2uuid(shareHex),
removed: true,
object_removed: objectRemoved,
new_root_uid: successorHex ? HEX2uuid(successorHex) : null,
},
};
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Ein **bestehendes** Repo an ein Projekt haengen (verlangt **changeable**).
*
* Das legt einen **Ableger** an: ein eigenes `ObjectBase`-Objekt mit eigener
* UID, das in `UIDBelongsTo` auf den **Basis-Share** (= der Root, die
* Index-Identitaet) zeigt und die projekt-eigenen Einstellungen traegt. Der
* Basis-Share bleibt unangetastet — derselbe Code wird damit **einmal**
* indexiert, auch wenn er in mehreren Projekten haengt
* ([Shares eines Projekts](https://members.app.commtool.org/-/001-Backend/Datenstruktur/dProjectShares)).
*
* **Harte Regel: ein Root hoechstens einmal je Projekt.** Haengt bereits ein
* Objekt desselben Roots am Projekt, ist der Aufruf idempotent (es ist genau
* dieses Objekt) oder ein `409 SHARE_ALREADY_IN_PROJECT`. Damit kann derselbe
* Root nicht als Basis **und** als Ableger im selben Projekt liegen.
*
* `linkType` wird nur noch **validiert** (Alt-Alias), nie geschrieben: der
* Modus ist eine Eigenschaft des Shares (§13.9 Punkt 3).
*
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @param {string} shareUid
* @returns {Promise<Object>}
*/
export const linkShare = async (req, projectUid, shareUid) => {
try {
const projectHex = UUID2hex(projectUid);
const shareHex = UUID2hex(shareUid);
const userHex = UUID2hex(req.session.user);
await requireProjectChangeable(req, projectHex);
const body = req.body || {};
const linkType = typeof body.linkType === 'undefined' ? 'memberA' : body.linkType;
// `linkType` wird nur noch **validiert**, nicht mehr geschrieben.
//
// Der Link traegt keinen Level mehr (§13.6: genau ein Link-Typ,
// `memberA`), und `metadata.mode` gehoert ausschliesslich in
// `updateShare`/`RepoModal` (§13.9 Punkt 3). Vorher setzte dieser Pfad bei
// `read` ein `metadata.mode = readOnly` — und **nie** zurueck: der
// Schalter konnte einen Share sperren, aber nicht entsperren. Der Modus
// muss in beide Richtungen setzbar bleiben; das ist eine Eigenschaft des
// Shares, nicht des Links. Unbekannte Werte bleiben ein 422.
if (modeFromLinkType(linkType) === null) {
throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
}
// Das gewaehlte Objekt auf seinen Root ziehen. Der Root ist die Vorlage:
// seine Metadaten (gitUrl, repositoryKey, mode …) gelten fuer alle
// Projekte. Ein projekt-lokaler Wert am gewaehlten Ableger wird **nicht**
// weitergegeben — sonst traegt P1 die Sperre von P2.
const picked = await rootOfShare(shareHex);
if (!picked) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found');
if (await getOrganizationForShare(shareHex) !== HEX2uuid(req.session.root)) {
throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found in this organization');
}
const [base] = await query(
`SELECT UID, Type, Title, Display, SortName, Data FROM ObjectBase
WHERE UID=? AND Type IN (${SHARE_TYPES_SQL})`,
[picked.root],
{ cast: ['json'] },
);
if (!base) throw apiError(404, 'SHARE_ROOT_NOT_FOUND', 'Base share not found');
// Ein Root hoechstens einmal je Projekt.
const rootKey = HEX2uuid(picked.root);
const already = (await projectShareRoots(projectHex)).find((r) => HEX2uuid(r.root) === rootKey);
if (already) {
if (HEX2uuid(already.share) === HEX2uuid(shareHex)) {
const row = await readShare(shareHex, projectHex);
const linked = row ? toShare(row, HEX2uuid(projectHex)) : null;
// Idempotent: das Projekt hat diesen Root schon — ueber genau
// dieses Objekt. Kein zweiter Traeger, kein Fehler.
return {
success: true,
result: linked
? { ...linked, level: linked.linkType, root_uid: rootKey, created: false }
: { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), root_uid: rootKey, created: false },
};
}
throw apiError(409, 'SHARE_ALREADY_IN_PROJECT', 'This repository is already part of the project', {
share_uid: HEX2uuid(already.share),
root_uid: rootKey,
});
}
const metadata = base.Data?.metadata ?? {};
const title = base.Title || (typeof metadata.repositoryKey === 'string' && metadata.repositoryKey) || 'share';
const UID = await getUID(req);
const events = [];
// Vorher-Stand der Rechte-Zeilen: beim Anlegen eines Ablegers leer, aber
// gemessen statt angenommen (siehe addShare).
const beforeOwner = await loadVisibleScopes([UID]);
await transaction(async (connection) => {
// Ableger: eigene UID, `UIDBelongsTo` = Basis-Share (Root).
await connection.query(
`INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[UID, base.Type, picked.root, title, base.Display || title, base.SortName || title, 0, JSON.stringify({ metadata }), userHex],
);
// Neue Richtung: der Ableger zeigt auf sein Projekt.
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
[UID, projectHex, userHex],
);
// Owner: wer den Ableger anlegt, besitzt ihn (wie beim Anlegen).
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'member', ?, ?)`,
[UID, userHex, userHex],
);
await connection.query(
`INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
[UID, userHex],
);
// Rechte-Vererbung: Filter-Satz des Projekts auf den **Ableger** spiegeln.
await mirrorProjectFilters(projectHex, UID, connection);
if (PROJECT_EVENTS_ENABLED) {
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
await writeEventLog(`/add/${base.Type}/${HEX2uuid(UID)}`, payload, connection);
events.push({ key: `/add/${base.Type}/${HEX2uuid(UID)}`, payload });
const linkPayload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
await writeEventLog(`/add/project/write/${HEX2uuid(projectHex)}`, linkPayload, connection);
events.push({ key: `/add/project/write/${HEX2uuid(projectHex)}`, payload: linkPayload });
}
});
for (const ev of events) {
publishToRedis(ev.key, ev.payload);
}
// Owner-Zeile melden (siehe addShare): sie entsteht **in** der
// Transaktion, der Rebuild sieht sie daher nie als Delta.
publishVisibilityDiff(beforeOwner, await loadVisibleScopes([UID]), req.session.root);
// Rechte materialisieren: Owner + geerbte Projekt-Rechte + Rechte-Events.
await reconcileShare(req, UID);
const row = await readShare(UID, projectHex);
const linked = row
? toShare(row, HEX2uuid(projectHex))
: { UID: HEX2uuid(UID), project_uid: HEX2uuid(projectHex), Type: base.Type, linkType: levelOf(metadata), metadata };
return { success: true, result: { ...linked, level: linked.linkType, root_uid: rootKey, created: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Remove a link between a share and a project (requires **changeable**).
* Fires the matching level event; the share object itself is never removed here.
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @param {string} shareUid
* @returns {Promise<Object>}
*/
export const unlinkShare = async (req, projectUid, shareUid) => {
try {
const projectHex = UUID2hex(projectUid);
await requireProjectChangeable(req, projectHex);
// Root **oder** Projekt-Objekt (Ableger) werden akzeptiert — wie beim
// Loeschen, und eindeutig durch die harte Root-Regel.
const shareHex = await resolveProjectShare(projectHex, UUID2hex(shareUid));
if (!shareHex) throw apiError(404, 'SHARE_LINK_NOT_FOUND', 'Share is not linked to this project');
const [link] = await query(
`SELECT Type FROM Links
WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
AND ((UID=? AND UIDTarget=?) OR (UID=? AND UIDTarget=?))
LIMIT 1`,
[projectHex, shareHex, shareHex, projectHex],
);
if (!link) throw apiError(404, 'SHARE_LINK_NOT_FOUND', 'Share is not linked to this project');
const events = [];
await transaction(async (connection) => {
await connection.query(
`DELETE Links FROM Links
WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
AND ((UID=? AND UIDTarget=?) OR (UID=? AND UIDTarget=?))`,
[projectHex, shareHex, shareHex, projectHex],
);
if (PROJECT_EVENTS_ENABLED) {
const level = link.Type === 'memberA' ? 'write' : 'read';
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
await writeEventLog(`/remove/project/${level}/${HEX2uuid(projectHex)}`, payload, connection);
events.push({ key: `/remove/project/${level}/${HEX2uuid(projectHex)}`, payload });
}
});
for (const ev of events) {
publishToRedis(ev.key, ev.payload);
}
// Rechte nachziehen: ohne Projekt-Link fallen die gespiegelten Filter und
// die geerbten Zeilen weg; Owner und Org-Superadmin bleiben.
await reconcileShare(req, shareHex);
return { success: true, result: { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), removed: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};