sync: update from private repo (caeb95c)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-09 12:59:57 +00:00
parent 2ec9853655
commit 454d6f957b
12 changed files with 628 additions and 13 deletions
+65
View File
@@ -98,4 +98,69 @@ export function mountAdminApi(
deleteUserFolder(userFolderRoot, id);
res.status(204).end();
});
// ── Local organizations (admin) ───────────────────────────────────────
// A provider-agnostic 'org' visibility scope for local accounts. See
// docs/superpowers/plans/2026-06-09-local-orgs.md.
app.get('/api/admin/orgs', guard, (_req: Request, res: Response) => {
if (!authActive) { res.json([]); return; }
const orgs = repo.listLocalOrgs().map(o => ({ ...o, members: repo.listOrgMembers(o.id) }));
res.json(orgs);
});
app.post('/api/admin/orgs', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { name } = (req.body ?? {}) as { name?: string };
if (typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'name is required' });
return;
}
const createdBy = (req.user as { id?: string } | undefined)?.id ?? null;
res.status(201).json(repo.createLocalOrg(name.trim(), createdBy));
});
app.patch('/api/admin/orgs/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const { name } = (req.body ?? {}) as { name?: string };
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name is required' }); return; }
repo.renameLocalOrg(id, name.trim());
res.json(repo.getLocalOrg(id));
});
app.delete('/api/admin/orgs/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
// repo downgrades org-scoped tasks/schedules/jobs/notes to private first.
repo.deleteLocalOrg(id);
res.status(204).end();
});
app.post('/api/admin/orgs/:id/members', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const { userId, role } = (req.body ?? {}) as { userId?: string; role?: string };
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
if (typeof userId !== 'string' || !repo.getUserById(userId)) {
res.status(400).json({ error: 'a valid userId is required' });
return;
}
repo.addOrgMember(id, userId, role === 'owner' ? 'owner' : 'member');
// The added user's session orgIds change — drop their sessions so the next
// request re-derives membership (and 'org'-visible resources appear).
repo.deleteSessionsByUserId(userId);
res.status(204).end();
});
app.delete('/api/admin/orgs/:id/members/:userId', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id, userId } = req.params;
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
repo.removeOrgMember(id, userId);
repo.deleteSessionsByUserId(userId);
res.status(204).end();
});
}
+14 -4
View File
@@ -81,6 +81,17 @@ export function isLocalEnabled(authConfig: AuthConfig): boolean {
return authConfig.local?.enabled === true;
}
/**
* The org ids a user belongs to, for session.orgIds. Union of Gitea orgs
* (from OAuth login) and local orgs (membership). buildVisibilityWhere is
* provider-agnostic, so 'org' visibility works for either once these are set.
*/
export function resolveOrgIds(repo: Repository, userId: string): string[] {
const gitea = repo.listUserGiteaOrgs(userId).map(o => o.orgId);
const local = repo.listUserLocalOrgs(userId).map(o => o.orgId);
return [...new Set([...gitea, ...local])];
}
/**
* Self-service password change for the authenticated local user. Mount behind
* requireAuth + a JSON body parser. Requires the CURRENT password (so a
@@ -497,7 +508,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
if (updated) user = updated;
}
await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
const orgIds = resolveOrgIds(repo, user.id);
const sessionUser: Express.User = {
...user,
orgIds,
@@ -589,7 +600,7 @@ function createAuthRouter(
const toExpressUser = (u: User): Express.User => ({
...u,
orgIds: [],
orgIds: resolveOrgIds(repo, u.id),
defaultVisibility: u.defaultVisibility ?? 'private',
defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
});
@@ -717,10 +728,9 @@ export function setupAuth(
try {
const baseUser = repo.getUserById(id);
if (!baseUser) { done(null, false); return; }
const orgs = repo.listUserGiteaOrgs(id);
const enriched: Express.User = {
...baseUser,
orgIds: orgs.map(o => o.orgId),
orgIds: resolveOrgIds(repo, id),
defaultVisibility: baseUser.defaultVisibility ?? 'private',
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
};
+75
View File
@@ -0,0 +1,75 @@
/**
* End-to-end: a local-org member sees 'org'-scoped resources; a non-member
* does not. Validates the whole chain — local org membership → resolveOrgIds
* → session.orgIds → the provider-agnostic buildVisibilityWhere.
*
* See docs/superpowers/plans/2026-06-09-local-orgs.md.
*/
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { resolveOrgIds } from './auth.js';
function viewer(id: string, orgIds: string[]): Express.User {
return {
id, email: `${id}@x.com`, name: id, avatarUrl: null,
role: 'user', status: 'active', orgIds,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
describe('local orgs — org visibility E2E', () => {
let tempDir = '';
let repo: Repository;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-orgvis-'));
repo = new Repository(join(tempDir, 'orchestrator.db'));
runMigrations(repo.getDb());
});
afterEach(() => {
repo.close();
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
it('resolveOrgIds includes the local orgs a user belongs to', () => {
const alice = repo.createUser({ email: '[email protected]', name: 'A', role: 'user', status: 'active' }).id;
const org = repo.createLocalOrg('Team', alice);
repo.addOrgMember(org.id, alice);
expect(resolveOrgIds(repo, alice)).toContain(org.id);
});
it('an org member sees an org-scoped task; a non-member does not', async () => {
const carol = repo.createUser({ email: '[email protected]', name: 'Carol', role: 'user', status: 'active' }).id;
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' }).id;
const bob = repo.createUser({ email: '[email protected]', name: 'Bob', role: 'user', status: 'active' }).id;
const org = repo.createLocalOrg('Team', carol);
repo.addOrgMember(org.id, carol);
repo.addOrgMember(org.id, alice); // alice is a member, bob is not
await repo.createLocalTask({
title: 'org task', body: 'b', ownerId: carol,
visibility: 'org', visibilityScopeOrgId: org.id,
});
const aliceTasks = await repo.listLocalTasks({ viewer: viewer(alice, resolveOrgIds(repo, alice)) });
const bobTasks = await repo.listLocalTasks({ viewer: viewer(bob, resolveOrgIds(repo, bob)) });
expect(aliceTasks.some(t => t.title === 'org task')).toBe(true); // member sees it
expect(bobTasks.some(t => t.title === 'org task')).toBe(false); // non-member does not
});
it('the org name resolves on display after the COALESCE extension', async () => {
const carol = repo.createUser({ email: '[email protected]', name: 'C', role: 'user', status: 'active' }).id;
const org = repo.createLocalOrg('Engineering', carol);
repo.addOrgMember(org.id, carol);
const t = await repo.createLocalTask({
title: 'x', body: 'b', ownerId: carol, visibility: 'org', visibilityScopeOrgId: org.id,
});
const got = await repo.getLocalTask(t.id, { viewer: viewer(carol, resolveOrgIds(repo, carol)) });
expect(got?.visibilityScopeOrgName).toBe('Engineering');
});
});
+4 -4
View File
@@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler } from './auth.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds } from './auth.js';
import { canUserSeeTask } from './visibility.js';
import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
@@ -698,7 +698,7 @@ export function createCoreServer(opts: CoreServerOptions): {
getUserAccess: (userId) => {
const user = repo.getUserById(userId);
const isAdmin = user?.role === 'admin';
const orgIds = repo.listUserGiteaOrgs(userId).map((o) => o.orgId);
const orgIds = resolveOrgIds(repo, userId);
return { isAdmin, orgIds };
},
sshExec,
@@ -757,7 +757,7 @@ export function createCoreServer(opts: CoreServerOptions): {
avatarUrl: null,
role: (user.role === 'admin' ? 'admin' : 'user'),
status: 'active',
orgIds: repo.listUserGiteaOrgs(user.id).map((o) => o.orgId),
orgIds: resolveOrgIds(repo, user.id),
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
@@ -773,7 +773,7 @@ export function createCoreServer(opts: CoreServerOptions): {
resolveSshAccess: async (user, session, task) => {
const connection = connectionRepo.resolveConnection(session.connectionId);
if (!connection) return false;
const orgIds = repo.listUserGiteaOrgs(user.id).map((o) => o.orgId);
const orgIds = resolveOrgIds(repo, user.id);
const decision = accessResolver.resolveAccess({
connection,
userId: user.id,
+7 -3
View File
@@ -15,7 +15,10 @@ const passthrough: RequestHandler = (_req, _res, next) => next();
export function mountUsersApi(app: Application, repo: Repository, authActive = true): void {
const guard = authActive ? requireAuth : passthrough;
// Viewer's cached Gitea orgs (populated at OAuth callback by the gitea strategy).
// Viewer's orgs for the visibility ('org' scope) picker: Gitea orgs (cached
// at OAuth callback) + local orgs (membership). Returning both here means
// every picker (CreateTaskDialog / Schedules / DetailPanel / Preferences)
// lists local orgs without any client change.
app.get('/api/users/me/orgs', guard, (req: Request, res: Response) => {
const user = req.user as Express.User | undefined;
if (!user) {
@@ -23,8 +26,9 @@ export function mountUsersApi(app: Application, repo: Repository, authActive = t
res.status(401).json({ error: 'Unauthorized' });
return;
}
const orgs = repo.listUserGiteaOrgs(user.id);
res.json({ orgs });
const gitea = repo.listUserGiteaOrgs(user.id);
const local = repo.listUserLocalOrgs(user.id).map(o => ({ orgId: o.orgId, orgName: o.name, fetchedAt: '' }));
res.json({ orgs: [...gitea, ...local] });
});
// Update viewer's per-user preferences (currently just default visibility).