This commit is contained in:
+141
-47
@@ -15,6 +15,7 @@ import {
|
||||
clearSetupToken,
|
||||
} from './setup-api.js';
|
||||
import { mountPiecesApi } from './pieces-api.js';
|
||||
import { resolveSpaceFolder } from '../spaces/folder-resolver.js';
|
||||
import { mountToolsApi } from './tools-api.js';
|
||||
import { mountSkillsApi } from './skills-api.js';
|
||||
import { mountNotificationsApi } from './notifications-api.js';
|
||||
@@ -31,10 +32,10 @@ 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, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
|
||||
import { canUserSeeTask } from './visibility.js';
|
||||
import { canUserSeeTask, canEditInSpace } from './visibility.js';
|
||||
import { mountAdminApi } from './admin-api.js';
|
||||
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
||||
import { mountUsersApi } from './users-api.js';
|
||||
import { mountUsersApi, mountUsersPickableApi } from './users-api.js';
|
||||
import { mountShareApi } from './share-api.js';
|
||||
import { securityHeadersMiddleware } from './security-headers.js';
|
||||
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||
@@ -45,6 +46,8 @@ import { createUserFolderApi } from './user-folder-api.js';
|
||||
import { createMemoryApi } from './memory-api.js';
|
||||
import { createReflectionApi } from './reflection-api.js';
|
||||
import { createDashboardApi } from './dashboard-api.js';
|
||||
import { createSpaceApi } from './space-api.js';
|
||||
import { createCrossCalendarApi } from './cross-calendar-api.js';
|
||||
import { registerShutdownHook, installSignalHandlers } from './shutdown.js';
|
||||
import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js';
|
||||
import { createWorkerRegistry } from '../metrics/registry.js';
|
||||
@@ -65,7 +68,7 @@ import { executeMcpCall } from '../mcp/tool-executor.js';
|
||||
import { refreshAccessToken } from '../mcp/discovery.js';
|
||||
import { setMcpAggregator } from '../engine/tools/index.js';
|
||||
import { setMcpToolLookup } from '../engine/tools/docs.js';
|
||||
import { setDashboardRepo } from '../engine/tools/dashboard.js';
|
||||
import { setCalendarRepo } from '../engine/tools/calendar.js';
|
||||
import { createMcpOauthRouter } from '../mcp/oauth-routes.js';
|
||||
import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js';
|
||||
import { mergeMcpConfig } from '../mcp/config.js';
|
||||
@@ -107,9 +110,6 @@ import {
|
||||
type SimpleUser,
|
||||
} from './console-ws-api.js';
|
||||
import { createConsoleAdminRouter } from './console-admin-api.js';
|
||||
import { NotesRepository } from '../notes/notes-repository.js';
|
||||
import { NotesService } from '../notes/notes-service.js';
|
||||
import { createNotesApi } from './notes-api.js';
|
||||
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
||||
import { readGatewayConfig } from '../gateway/config.js';
|
||||
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
||||
@@ -210,7 +210,14 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// Referrer-Policy, and HSTS over TLS). File-serving endpoints add
|
||||
// `Content-Security-Policy: sandbox` on top (see setUntrustedFileResponseHeaders)
|
||||
// to neutralize stored XSS from agent/user-authored workspace files.
|
||||
app.use(securityHeadersMiddleware());
|
||||
//
|
||||
// HSTS policy is read lazily: serverCfg (with the resolved tls block) is built
|
||||
// further down, but the middleware only runs the thunk per request — long
|
||||
// after serverCfg is assigned. Default 'clear' until then. HSTS is opt-in
|
||||
// (server.tls.hsts); when off we emit `max-age=0` to un-pin browsers that an
|
||||
// earlier build pinned (see security-headers.ts).
|
||||
let hstsPolicy: import('./security-headers.js').HstsPolicy = 'clear';
|
||||
app.use(securityHeadersMiddleware(() => hstsPolicy));
|
||||
|
||||
// リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
|
||||
if (opts.authConfig?.secureCookie) {
|
||||
@@ -313,6 +320,12 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
httpsPort: opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port),
|
||||
});
|
||||
|
||||
// Now that the TLS block is resolved, set the HSTS policy the lazy
|
||||
// securityHeadersMiddleware thunk reads. Only 'enable' when HTTPS is on AND
|
||||
// the operator opted in; otherwise 'clear' (emit max-age=0) so a previously
|
||||
// pinned browser recovers the next time it loads the app over HTTPS.
|
||||
hstsPolicy = serverCfg.tls.enabled && serverCfg.tls.hsts ? 'enable' : 'clear';
|
||||
|
||||
if (authActive) {
|
||||
// Idempotently seed the shared `local` system admin (id='local', the same
|
||||
// owner the no-auth path uses) so an existing single-user / no-auth
|
||||
@@ -381,6 +394,9 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// is enforced inside pieces-api.ts handlers.
|
||||
app.use('/api/pieces', requireAuth);
|
||||
app.use('/api/usage', requireAuth);
|
||||
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
|
||||
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
|
||||
app.use('/api/calendar', requireAuth);
|
||||
// Scheduled tasks: any authenticated user can create/list (visibility-filtered).
|
||||
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
|
||||
app.use('/api/scheduled-tasks', requireAuth);
|
||||
@@ -444,6 +460,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active)
|
||||
mountUsersApi(app, repo, authActive);
|
||||
|
||||
// /api/users/pickable — active-user picker for space-member invites.
|
||||
// Gated by requireAuth (NOT requireAdmin): any authed user may invite. Returns
|
||||
// [] in no-auth mode (no other users to invite).
|
||||
mountUsersPickableApi(app, repo, authActive);
|
||||
|
||||
// --- Share API (public + authenticated routes) ---
|
||||
mountShareApi(app, repo);
|
||||
|
||||
@@ -544,7 +565,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
});
|
||||
setMcpAggregator(mcpAggregator);
|
||||
setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName));
|
||||
setDashboardRepo(repo);
|
||||
setCalendarRepo(repo);
|
||||
opts.workerManager?.setMcpDeps({ tokenManager: mcpTokenManager });
|
||||
|
||||
// Expose MCP enumeration to /api/tools so the Piece allowed_tools editor
|
||||
@@ -601,6 +622,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
}));
|
||||
|
||||
@@ -622,7 +644,33 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
toolCache: mcpToolCache,
|
||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
// No-auth single-user mode: passport is not mounted, so req.user is
|
||||
// never populated. Fall back to the synthetic `local` admin (the same
|
||||
// owner the SSH user router and space API use) so per-user / per-space
|
||||
// MCP server management stays usable; otherwise getUserId is null and
|
||||
// every register/list 401s, making the per-space MCP panel dead in
|
||||
// no-auth deployments. No-op when auth is active (req.user wins).
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? (authActive ? null : 'local'),
|
||||
// Mirror the no-auth fallback: when auth is off and passport hasn't
|
||||
// populated a viewer, authorize the space against the synthetic `local`
|
||||
// admin so getSpace doesn't reject the (null-owner) local space.
|
||||
getSpace: (spaceId, viewer) =>
|
||||
repo.getSpace(spaceId, {
|
||||
viewer: viewer ?? (authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User)),
|
||||
}),
|
||||
// Space-as-Principal P2: edit-rights check so space members can share the
|
||||
// management of space-owned api_key MCP servers. Mirrors the no-auth
|
||||
// synthetic local admin used by getUserId/getSpace above.
|
||||
canEditSpace: async (spaceId, req) => {
|
||||
const viewer =
|
||||
(req.user as Express.User | undefined) ??
|
||||
(authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User));
|
||||
if (!viewer) return false;
|
||||
const space = await repo.getSpace(spaceId, { viewer });
|
||||
if (!space) return false;
|
||||
const role = repo.getSpaceMemberRole(spaceId, viewer.id);
|
||||
return canEditInSpace(viewer, { ownerId: space.ownerId }, role);
|
||||
},
|
||||
insecureLocalTestMode: false,
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
}));
|
||||
@@ -682,6 +730,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin',
|
||||
getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
|
||||
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
|
||||
connectionRepo,
|
||||
grantsRepo,
|
||||
auditRepo,
|
||||
@@ -689,23 +738,23 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
accessResolver,
|
||||
maintenance: sshMaintenance,
|
||||
forceUnlockLimiter,
|
||||
encryptKeyMaterial: (ownerId, pem, passphrase) => {
|
||||
const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem);
|
||||
encryptKeyMaterial: (ownerId, pem, passphrase, spaceId) => {
|
||||
const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem, spaceId);
|
||||
const passphraseBlob = passphrase
|
||||
? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase).blob
|
||||
? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase, spaceId).blob
|
||||
: null;
|
||||
const fingerprint = sshComputeKeyFingerprint(pem, passphrase);
|
||||
const publicKey = sshFormatPublicKey(pem, passphrase);
|
||||
return { blob, passphraseBlob, keyVersion, fingerprint, publicKey };
|
||||
},
|
||||
decryptKeyMaterial: (ownerId, blob) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob),
|
||||
decryptPassphrase: (ownerId, blob) =>
|
||||
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob) : null,
|
||||
decryptKeyMaterial: (ownerId, blob, spaceId) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
|
||||
decryptPassphrase: (ownerId, blob, spaceId) =>
|
||||
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
|
||||
generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType),
|
||||
derivePublicKey: (ownerId, blob, passphraseBlob) => {
|
||||
const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob);
|
||||
derivePublicKey: (ownerId, blob, passphraseBlob, spaceId) => {
|
||||
const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId);
|
||||
const pass = passphraseBlob
|
||||
? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob)
|
||||
? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob, spaceId)
|
||||
: null;
|
||||
try {
|
||||
return sshFormatPublicKey(pem, pass);
|
||||
@@ -757,10 +806,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
auditRepo,
|
||||
abuseRepo,
|
||||
accessResolver,
|
||||
decryptKeyMaterial: (ownerId, blob) =>
|
||||
sshDecryptPrivateKey(repo.getDb(), ownerId, blob),
|
||||
decryptPassphrase: (ownerId, blob) =>
|
||||
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob) : null,
|
||||
decryptKeyMaterial: (ownerId, blob, spaceId) =>
|
||||
sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
|
||||
decryptPassphrase: (ownerId, blob, spaceId) =>
|
||||
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
|
||||
getUserAccess: (userId) => {
|
||||
const user = repo.getUserById(userId);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
@@ -922,6 +971,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir,
|
||||
userFolderRoot: loadConfig().userFolderRoot ?? './data/users',
|
||||
authActive,
|
||||
generateTitle: opts.generateTitle,
|
||||
selectPiece: opts.selectPiece,
|
||||
@@ -950,6 +1000,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// --- Subtask activity API ---
|
||||
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
|
||||
app.use('/api/usage', createUsageRouter(repo, { authActive }));
|
||||
// 横断(全スペース)カレンダー: GET /api/calendar/cross
|
||||
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
|
||||
|
||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||
mountSubtaskFilesApi(app, repo);
|
||||
@@ -1042,10 +1094,17 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
mountBrandingApi(app, opts.configManager, { brandingDir, adminGuard: brandingGuard });
|
||||
|
||||
if (opts.piecesDir) {
|
||||
const piecesUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
|
||||
mountPiecesApi(app, {
|
||||
piecesDir: opts.piecesDir,
|
||||
customPiecesDir: opts.customPiecesDir,
|
||||
userPiecesRootDir: loadConfig().userFolderRoot ?? './data/users',
|
||||
userPiecesRootDir: piecesUserFolderRoot,
|
||||
// Spaces foundation: resolve a request's spaceId to its pieces folder.
|
||||
// No spaceId on the request => legacy per-user resolution (unchanged).
|
||||
resolveSpaceFolder: async (spaceId: string, viewer) => {
|
||||
const space = await repo.getSpace(spaceId, { viewer: viewer as Express.User | undefined });
|
||||
return space ? resolveSpaceFolder(space, piecesUserFolderRoot) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1056,6 +1115,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
});
|
||||
|
||||
if (opts.skillCatalog) {
|
||||
const skillsUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
|
||||
mountSkillsApi(app, {
|
||||
skillCatalog: opts.skillCatalog,
|
||||
requireAuth,
|
||||
@@ -1064,6 +1124,13 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
auditLog: async (jobId, action, actor, detail) => {
|
||||
try { await repo.addAuditLog(jobId, action, actor, detail); } catch {}
|
||||
},
|
||||
// Spaces foundation: resolve a request's spaceId to its skills folder,
|
||||
// gated on the viewer's visibility of the space. No spaceId => legacy
|
||||
// per-user resolution (unchanged).
|
||||
resolveSpaceFolder: async (spaceId, viewer) => {
|
||||
const space = await repo.getSpace(spaceId, { viewer });
|
||||
return space ? resolveSpaceFolder(space, skillsUserFolderRoot) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1105,40 +1172,66 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
app.use(
|
||||
'/api/browser-sessions',
|
||||
express.json(),
|
||||
createBrowserSessionApi({ sessRepo, sessionManager: browserSessionManager, masterKeyPath, authActive }),
|
||||
createBrowserSessionApi({
|
||||
sessRepo,
|
||||
sessionManager: browserSessionManager,
|
||||
masterKeyPath,
|
||||
authActive,
|
||||
spaceAccess: {
|
||||
// Mirror the MCP/folder wiring's no-auth handling. Under no-auth the
|
||||
// browser-session API mints a synthetic 'local' req.user that carries no
|
||||
// orgIds — passing it straight to buildVisibilityWhere crashes, and even
|
||||
// normalized it cannot see the null-owner local space (non-admin, no
|
||||
// membership row). So when auth is OFF, always resolve as a synthetic
|
||||
// admin local viewer. When auth is ON, pass the real viewer through
|
||||
// (defaulting orgIds to [] defensively).
|
||||
getSpace: (spaceId, viewer) =>
|
||||
repo.getSpace(spaceId, {
|
||||
viewer: authActive
|
||||
? (viewer && !(viewer as { orgIds?: string[] }).orgIds
|
||||
? ({ ...viewer, orgIds: [] } as unknown as Express.User)
|
||||
: viewer)
|
||||
: ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User),
|
||||
}),
|
||||
getSpaceMemberRole: (spaceId, userId) => repo.getSpaceMemberRole(spaceId, userId),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-user folder REST API: list / read / write / delete with trash.
|
||||
const userFolderRoot = loadConfig().userFolderRoot ?? './data/users';
|
||||
|
||||
// Shared NotesService: one instance for the entire app lifetime.
|
||||
// getUserOrgIds uses the same repo.listUserGiteaOrgs pattern as SSH / MCP auth.
|
||||
const notesRepo = new NotesRepository(repo.getDb());
|
||||
const notesService = new NotesService({
|
||||
db: repo.getDb(),
|
||||
repo: notesRepo,
|
||||
userFolderRoot,
|
||||
getUserOrgIds: (userId) => repo.listUserGiteaOrgs(userId).map((o) => o.orgId),
|
||||
audit: (action, actor, target) => {
|
||||
try {
|
||||
repo.addAuditLog(null, action, actor, { target });
|
||||
} catch (err) {
|
||||
logger.warn(`[notes-audit] failed: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
// Spaces foundation: resolve a request's spaceId to its folder, gated on the
|
||||
// viewer's visibility of that space. Returns null when the space is not found
|
||||
// or not visible to the viewer (callers then return 404 — no silent fallback).
|
||||
// No spaceId on a request => callers use the legacy per-user folder (unchanged).
|
||||
const resolveSpaceFolderForViewer = async (
|
||||
spaceId: string,
|
||||
viewer?: Express.User,
|
||||
): Promise<{ rootDir: string; leafId: string } | null> => {
|
||||
const space = await repo.getSpace(spaceId, { viewer });
|
||||
return space ? resolveSpaceFolder(space, userFolderRoot) : null;
|
||||
};
|
||||
|
||||
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, notesService }));
|
||||
|
||||
// Notes knowledge-sharing REST API.
|
||||
app.use('/api/notes', createNotesApi({ service: notesService, authActive }));
|
||||
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));
|
||||
|
||||
// Per-user memory entries REST API (GET/PUT/DELETE).
|
||||
app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive }));
|
||||
app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));
|
||||
|
||||
// Per-user reflection history REST API (GET/POST).
|
||||
app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive }));
|
||||
|
||||
// スペース(個人 / 案件)CRUD REST API。
|
||||
// NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
|
||||
// upload ルートの大きい json parser より前に body を消費し、>100kb のアップロードが
|
||||
// 413 になるため。各ルートが自前の json parser を宣言する(space-api.ts 側)。
|
||||
app.use('/api/local/spaces', createSpaceApi({
|
||||
repo,
|
||||
dataRoot: userFolderRoot,
|
||||
worktreeDir: loadConfig().worktreeDir ?? './data/worktrees',
|
||||
authActive,
|
||||
}));
|
||||
|
||||
// BackendStatusRegistry singleton — probes every configured worker
|
||||
// (direct llama-server or proxy LiteLLM) at a fixed cadence so the
|
||||
// Side Info Panel's node-status widget can paint without hammering
|
||||
@@ -1152,7 +1245,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
});
|
||||
backendStatusRegistry.start();
|
||||
|
||||
// Dashboard widget + worker status REST API.
|
||||
// Worker / node (GPU) status REST API.
|
||||
app.use('/api/local/dashboard', express.json(), createDashboardApi({
|
||||
repo,
|
||||
getWorkers: () => loadConfig().provider.workers,
|
||||
@@ -1270,7 +1363,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
if (!Number.isFinite(taskIdNum)) return false;
|
||||
const task = await repo.getLocalTask(taskIdNum);
|
||||
if (!task) return false;
|
||||
return canUserSeeTask(user, task);
|
||||
const spaceAccessible = task.spaceId ? repo.userCanViewSpace(task.spaceId, user) : false;
|
||||
return canUserSeeTask(user, task, spaceAccessible);
|
||||
}
|
||||
// legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin
|
||||
const isOwner = session.userId === user.id;
|
||||
|
||||
Reference in New Issue
Block a user