// @ts-check
/**
* Share service - repository/directory share objects belonging to the
* organization (UIDBelongsTo=org), linked to one or more projects via
* Links.Type IN ('memberA','member').
*
* Storage model:
* - ObjectBase row, Type='repositoryShare'|'directoryShare', UIDBelongsTo=org UID
* - Data JSON: { metadata }
* - relation via Links.Type='memberA'|'member' (UID=project, UIDTarget=share),
* written in the same transaction as the object row.
* 'memberA' = write/admin (Master-Projekt), 'member' = read only.
*
* The wire format follows the App-Standard: UID / Type / project_uid / metadata
* plus `linkType` ('read'|'write') describing the project's link to the share.
*
* @import {ExpressRequestAuthorized} from '../../types.js'
*/
import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { isAdmin, isListAdmin, 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';
const SHARE_TYPES_SQL = `'repositoryShare','directoryShare'`;
/** @type {const} */
const LINK_TYPES = ['memberA', 'member'];
/** @type {const} */
const LINK_TYPES_SQL = `'memberA','member'`;
const LINK_LEVEL = { memberA: 'write', member: 'read' };
/**
* Verify the project exists in the organization and the user may administer it.
* @param {ExpressRequestAuthorized} req
* @param {string} projectHex
* @returns {Promise<void>}
*/
const requireProjectAdmin = async (req, projectHex) => {
const orgHex = UUID2hex(req.session.root);
const [project] = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type='project' AND UIDBelongsTo=?`,
[projectHex, orgHex],
);
if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
if (!(await isListAdmin(req, HEX2uuid(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 orgHex = UUID2hex(req.session.root);
const [project] = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type='project' AND UIDBelongsTo=?`,
[projectHex, orgHex],
);
if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
if (!(await isObjectVisible(req, projectHex))) {
throw apiError(403, 'PROJECT_NOT_ACCESSIBLE', 'Project is not accessible');
}
};
/**
* Read a share row that is linked to the given project (current state).
* Returns the share with the project's link type (link_type), or null when the
* share does not belong to the organization or has no link to this project.
* @param {string} shareHex
* @param {string} projectHex
* @param {string} orgHex
*/
const readShare = async (shareHex, projectHex, orgHex) => {
const rows = await query(
`SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo, ObjectBase.Data,
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.UID=? AND Links.UIDTarget=ObjectBase.UID AND Links.Type IN (${LINK_TYPES_SQL})
WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${SHARE_TYPES_SQL}) AND ObjectBase.UIDBelongsTo=?`,
[projectHex, shareHex, orgHex],
{ cast: ['json'] },
);
return rows[0] || null;
};
/**
* Map a share row to the app Share object (UID / Type / project_uid / metadata
* / linkType). The share belongs to the org, so project_uid comes from the
* project context, not from UIDBelongsTo.
* @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 || HEX2uuid(row.UIDBelongsTo),
Type: row.Type,
linkType: row.link_type ? LINK_LEVEL[row.link_type] : null,
metadata,
source_updated_at: row.source_updated_at,
};
};
/**
* Add a share to a project (requires admin on the project). The share is
* created with UIDBelongsTo=org and linked to the project as 'memberA'
* (write/admin = Master-Projekt).
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @returns {Promise<Object>}
*/
export const addShare = async (req, projectUid) => {
try {
const projectHex = UUID2hex(projectUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
await requireProjectAdmin(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');
}
const UID = await getUID(req);
const title = typeof metadata.repositoryKey === 'string' && metadata.repositoryKey ? metadata.repositoryKey : 'share';
const events = [];
await transaction(async (connection) => {
await connection.query(
`INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[UID, type, orgHex, title, title, title, 0, JSON.stringify({ metadata }), userHex],
);
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
[projectHex, UID, userHex],
);
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 });
// Master-Link (memberA → write): Projekt darf das Repo administrieren
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);
}
const row = await readShare(UID, projectHex, orgHex);
return {
success: true,
result: row
? toShare(row, HEX2uuid(projectHex))
: { UID: HEX2uuid(UID), project_uid: HEX2uuid(projectHex), Type: type, linkType: 'write', metadata },
};
} 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 memberA/member linked projects is visible
* to the user (admin users see all org shares). This is the server-side
* enforcement for the "add existing repository" flow.
*
* @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) {
visibilityJoin = `JOIN Links ON (Links.UIDTarget=ObjectBase.UID AND Links.Type IN (${LINK_TYPES_SQL}))
JOIN Visible ON (Visible.UID=Links.UID AND Visible.UIDUser=?)`;
params.push(userHex);
}
const where = [`ObjectBase.Type IN (${SHARE_TYPES_SQL})`, `ObjectBase.UIDBelongsTo=?`];
params.push(orgHex);
if (excludeProjectUid) {
where.push(
`NOT EXISTS (
SELECT 1 FROM Links ex
WHERE ex.UID=? AND ex.UIDTarget=ObjectBase.UID AND ex.Type IN (${LINK_TYPES_SQL})
)`
);
params.push(UUID2hex(excludeProjectUid));
}
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,
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 25`,
params,
{ cast: ['json'] },
);
return {
success: true,
result: rows.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 };
}),
};
} 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.
* @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,
Links.Type AS link_type,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM Links
JOIN ObjectBase ON ObjectBase.UID=Links.UIDTarget
WHERE Links.UID=? AND Links.Type IN (${LINK_TYPES_SQL})
AND ObjectBase.Type IN (${SHARE_TYPES_SQL})
ORDER BY ObjectBase.UID`,
[projectHex],
{ cast: ['json'] },
);
return { success: true, result: rows.map((r) => toShare(r, HEX2uuid(projectHex))) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Update share metadata (requires admin on the project AND a 'memberA' link,
* i.e. the project is a master for the repo).
* @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 orgHex = UUID2hex(req.session.root);
const shareHex = UUID2hex(shareUid);
const userHex = UUID2hex(req.session.user);
await requireProjectAdmin(req, projectHex);
const existing = await readShare(shareHex, projectHex, orgHex);
if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
if (existing.link_type !== 'memberA') {
throw apiError(403, 'SHARE_NOT_MASTER', 'Only a master (memberA) project may administrate this share');
}
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');
}
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}) AND UIDBelongsTo=?`,
[title, title, title, JSON.stringify({ metadata }), userHex, shareHex, orgHex],
);
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, orgHex);
return { success: true, result: row ? toShare(row, HEX2uuid(projectHex)) : existing };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Delete a share (requires admin on the project AND a 'memberA' link). The
* project's link is removed; the share object itself is only deleted when no
* further links exist.
* @param {ExpressRequestAuthorized} req
* @param {string} projectUid
* @param {string} shareUid
* @returns {Promise<Object>}
*/
export const deleteShare = async (req, projectUid, shareUid) => {
try {
const projectHex = UUID2hex(projectUid);
const orgHex = UUID2hex(req.session.root);
const shareHex = UUID2hex(shareUid);
await requireProjectAdmin(req, projectHex);
const existing = await readShare(shareHex, projectHex, orgHex);
if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
const linkType = existing.link_type === 'memberA' ? 'write' : 'read';
const events = [];
await transaction(async (connection) => {
await connection.query(
`DELETE Links FROM Links WHERE UID=? AND UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
[projectHex, shareHex],
);
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
// Achtung: connection.query liefert hier direkt das Rows-Array
// (`[{ n }]`), nicht `[rows, meta]` — daher kein `[[{ n }]]`.
const [{ n }] = await connection.query(
`SELECT COUNT(*) AS n FROM Links WHERE UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
[shareHex],
);
if (Number(n ?? 0) === 0) {
await connection.query(`DELETE FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES_SQL}) AND UIDBelongsTo=?`, [shareHex, orgHex]);
await connection.query(`DELETE FROM Visible WHERE UID=?`, [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);
}
return { success: true, result: { UID: HEX2uuid(shareHex), removed: true, object_removed: events.some((e) => e.key.startsWith(`/remove/${existing.Type}/`)) } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Link an existing share to a project (requires admin on the project).
* Sets a 'memberA' (write) or 'member' (read) link and fires the matching
* level event so rag-sync can maintain the projection.
* @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 orgHex = UUID2hex(req.session.root);
const shareHex = UUID2hex(shareUid);
const userHex = UUID2hex(req.session.user);
await requireProjectAdmin(req, projectHex);
const body = req.body || {};
const linkType = body.linkType;
if (!LINK_TYPES.includes(linkType)) {
throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
}
const [share] = await query(
`SELECT UID, Type FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES_SQL}) AND UIDBelongsTo=?`,
[shareHex, orgHex],
);
if (!share) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found in this organization');
const events = [];
await transaction(async (connection) => {
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, ?, ?, ?)`,
[projectHex, linkType, shareHex, userHex],
);
if (PROJECT_EVENTS_ENABLED) {
const level = LINK_LEVEL[linkType];
const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
await writeEventLog(`/add/project/${level}/${HEX2uuid(projectHex)}`, payload, connection);
events.push({ key: `/add/project/${level}/${HEX2uuid(projectHex)}`, payload });
}
});
for (const ev of events) {
publishToRedis(ev.key, ev.payload);
}
return { success: true, result: { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), linkType, level: LINK_LEVEL[linkType] } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Remove a link between a share and a project (requires admin on the project).
* 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);
const orgHex = UUID2hex(req.session.root);
const shareHex = UUID2hex(shareUid);
await requireProjectAdmin(req, projectHex);
const [link] = await query(
`SELECT Type FROM Links WHERE UID=? AND UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
[projectHex, shareHex],
);
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 UID=? AND UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
[projectHex, shareHex],
);
if (PROJECT_EVENTS_ENABLED) {
const level = LINK_LEVEL[link.Type];
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);
}
return { success: true, result: { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), removed: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};